Improve Team-indeling dashboard UI and cache invalidation

- Replace 'TEAM' label with Type attribute (Business/Enabling/Staf) in team blocks
- Make Type labels larger (text-sm) and brighter colors
- Make SUBTEAM label less bright (indigo-300) and smaller (text-[10px])
- Add 'FTE' suffix to bandbreedte values in header and application blocks
- Add Platform and Connected Device labels to application blocks
- Show Platform FTE and Workloads FTE separately in Platform blocks
- Add spacing between Regiemodel letter and count value
- Add cache invalidation for Team Dashboard when applications are updated
- Enrich team references with Type attribute in getSubteamToTeamMapping
This commit is contained in:
2026-01-10 02:16:55 +01:00
parent ea1c84262c
commit ca21b9538d
54 changed files with 13444 additions and 1789 deletions

View File

@@ -5,36 +5,6 @@ JIRA_SCHEMA_ID=your_schema_id
JIRA_API_BATCH_SIZE=20 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 # Claude API
ANTHROPIC_API_KEY=your_anthropic_api_key_here ANTHROPIC_API_KEY=your_anthropic_api_key_here

View File

@@ -93,19 +93,41 @@ zira-classificatie-tool/
│ ├── App.tsx # Main component with routing │ ├── App.tsx # Main component with routing
│ ├── index.css # Tailwind CSS imports │ ├── index.css # Tailwind CSS imports
│ ├── components/ │ ├── components/
│ │ ├── Dashboard.tsx # Overview statistics │ │ ├── SearchDashboard.tsx # Main dashboard with CMDB search
│ │ ├── ApplicationList.tsx # Search & filter view │ │ ├── Dashboard.tsx # App Component statistics
│ │ ── ApplicationDetail.tsx # Edit & AI classify │ │ ── ApplicationList.tsx # Application overview & filter
│ │ ├── ApplicationDetail.tsx # Edit & AI classify
│ │ ├── TeamDashboard.tsx # Team FTE dashboard
│ │ ├── ConfigurationV25.tsx # FTE configuration
│ │ └── ReportsDashboard.tsx # Reports overview
│ ├── services/api.ts # API client │ ├── services/api.ts # API client
│ ├── stores/ │ ├── stores/
│ │ ├── searchStore.ts # Filter state (Zustand) │ │ ├── searchStore.ts # Filter state (Zustand)
│ │ ── navigationStore.ts # Navigation state │ │ ── navigationStore.ts # Navigation state
│ │ └── authStore.ts # Authentication state
│ └── types/index.ts # TypeScript interfaces │ └── types/index.ts # TypeScript interfaces
└── data/ └── data/
├── zira-taxonomy.json ├── zira-taxonomy.json
└── management-parameters.json └── management-parameters.json
``` ```
## Navigation Structure
The application uses a hierarchical menu structure:
```
Dashboard (/) # CMDB search page
├── Application Component (/app-components)
│ ├── Dashboard (/app-components) # Statistics & overview
│ ├── Overzicht (/app-components/overview) # Application list & filter
│ └── FTE Config (/app-components/fte-config) # FTE calculation config
└── Rapporten (/reports)
├── Overzicht (/reports) # Reports dashboard
└── Team-indeling (/reports/team-dashboard) # Team FTE dashboard
```
## Key Domain Concepts ## Key Domain Concepts
### ZiRA (Ziekenhuis Referentie Architectuur) ### ZiRA (Ziekenhuis Referentie Architectuur)
@@ -134,17 +156,21 @@ Dutch hospital reference architecture with 90+ application functions organized i
```env ```env
# Jira Data Center # Jira Data Center
JIRA_HOST=https://jira.zuyderland.nl JIRA_HOST=https://jira.zuyderland.nl
JIRA_PAT=<personal_access_token> # Service account PAT (fallback when OAuth disabled)
JIRA_SCHEMA_ID=<schema_id> JIRA_SCHEMA_ID=<schema_id>
# Jira OAuth 2.0 (optional - enables user authentication) # Jira Authentication Method: 'pat' or 'oauth'
JIRA_OAUTH_ENABLED=false # Set to 'true' to enable OAuth JIRA_AUTH_METHOD=pat # Choose: 'pat' (Personal Access Token) or 'oauth' (OAuth 2.0)
# For PAT authentication (JIRA_AUTH_METHOD=pat)
JIRA_PAT=<personal_access_token> # Personal Access Token for Jira API access
# For OAuth 2.0 authentication (JIRA_AUTH_METHOD=oauth)
JIRA_OAUTH_CLIENT_ID=<oauth_client_id> # From Jira Application Link JIRA_OAUTH_CLIENT_ID=<oauth_client_id> # From Jira Application Link
JIRA_OAUTH_CLIENT_SECRET=<oauth_secret> # From Jira Application Link JIRA_OAUTH_CLIENT_SECRET=<oauth_secret> # From Jira Application Link
JIRA_OAUTH_CALLBACK_URL=http://localhost:3001/api/auth/callback JIRA_OAUTH_CALLBACK_URL=http://localhost:3001/api/auth/callback
JIRA_OAUTH_SCOPES=READ WRITE JIRA_OAUTH_SCOPES=READ WRITE
# Session Configuration # Session Configuration (required for OAuth)
SESSION_SECRET=<random_secret_string> # Change in production! SESSION_SECRET=<random_secret_string> # Change in production!
# Jira Object Type IDs # Jira Object Type IDs
@@ -179,17 +205,34 @@ FRONTEND_URL=http://localhost:5173
## Authentication ## Authentication
The application supports two authentication modes: The application supports two authentication methods, configured via `JIRA_AUTH_METHOD`:
### 1. Service Account Mode (Default) ### 1. Personal Access Token (PAT) Mode (`JIRA_AUTH_METHOD=pat`)
- Uses a single PAT (`JIRA_PAT`) for all Jira API calls - **Default mode** - Uses a single PAT for all Jira API calls
- Users don't need to log in - Users don't need to log in
- All changes are attributed to the service account - All changes are attributed to the service account
- Best for: Development, internal tools, or when user attribution isn't required
### 2. OAuth 2.0 Mode **Configuration:**
```env
JIRA_AUTH_METHOD=pat
JIRA_PAT=your_personal_access_token
```
### 2. OAuth 2.0 Mode (`JIRA_AUTH_METHOD=oauth`)
- Each user logs in with their own Jira credentials - Each user logs in with their own Jira credentials
- API calls are made under the user's account - API calls are made under the user's account
- Better audit trail and access control - Better audit trail and access control
- Best for: Production environments where user attribution matters
**Configuration:**
```env
JIRA_AUTH_METHOD=oauth
JIRA_OAUTH_CLIENT_ID=your_client_id
JIRA_OAUTH_CLIENT_SECRET=your_client_secret
JIRA_OAUTH_CALLBACK_URL=http://localhost:3001/api/auth/callback
SESSION_SECRET=your_secure_random_string
```
### Setting up OAuth 2.0 (Jira Data Center 8.14+) ### Setting up OAuth 2.0 (Jira Data Center 8.14+)
@@ -199,17 +242,11 @@ The application supports two authentication modes:
- Set Redirect URL: `http://localhost:3001/api/auth/callback` - Set Redirect URL: `http://localhost:3001/api/auth/callback`
- Note the Client ID and Secret - Note the Client ID and Secret
2. **Configure Environment:** 2. **Configure Environment Variables** (see above)
```env
JIRA_OAUTH_ENABLED=true
JIRA_OAUTH_CLIENT_ID=your_client_id
JIRA_OAUTH_CLIENT_SECRET=your_client_secret
JIRA_OAUTH_CALLBACK_URL=http://localhost:3001/api/auth/callback
```
3. **For Production:** 3. **For Production:**
- Update callback URL to production domain - Update callback URL to production domain
- Set `SESSION_SECRET` to a random string - Set `SESSION_SECRET` to a cryptographically secure random string
- Use HTTPS - Use HTTPS
## Implementation Notes ## Implementation Notes

View File

@@ -6,7 +6,8 @@
"scripts": { "scripts": {
"dev": "tsx watch src/index.ts", "dev": "tsx watch src/index.ts",
"build": "tsc", "build": "tsc",
"start": "node dist/index.js" "start": "node dist/index.js",
"generate-schema": "tsx scripts/generate-schema.ts"
}, },
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.32.1", "@anthropic-ai/sdk": "^0.32.1",

View File

@@ -0,0 +1,982 @@
#!/usr/bin/env npx tsx
/**
* Schema Generator - Fetches Jira Assets schema DYNAMICALLY and generates:
* - TypeScript types (jira-types.ts)
* - Schema metadata (jira-schema.ts)
* - Database schema (db-schema.sql)
*
* This script connects to the Jira Assets API to discover ALL object types
* and their attributes, ensuring the data model is always in sync with the
* actual CMDB configuration.
*
* Usage: npm run generate-schema
*/
import * as fs from 'fs';
import * as path from 'path';
import { fileURLToPath } from 'url';
import dotenv from 'dotenv';
// Load environment variables
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Try multiple possible .env locations
const envPaths = [
path.resolve(__dirname, '../../.env'), // backend/.env
path.resolve(__dirname, '../../../.env'), // project root .env
];
let envLoaded = '';
for (const envPath of envPaths) {
if (fs.existsSync(envPath)) {
dotenv.config({ path: envPath });
envLoaded = envPath;
break;
}
}
// Configuration
const JIRA_HOST = process.env.JIRA_HOST || '';
const JIRA_PAT = process.env.JIRA_PAT || '';
const JIRA_SCHEMA_ID = process.env.JIRA_SCHEMA_ID || '';
const OUTPUT_DIR = path.resolve(__dirname, '../src/generated');
// =============================================================================
// Interfaces
// =============================================================================
interface JiraObjectSchema {
id: number;
name: string;
objectSchemaKey: string;
description?: string;
objectCount?: number;
}
interface JiraObjectType {
id: number;
name: string;
description?: string;
iconId?: number;
objectCount?: number;
parentObjectTypeId?: number;
objectSchemaId: number;
inherited?: boolean;
abstractObjectType?: boolean;
parentObjectTypeInherited?: boolean;
}
interface JiraAttribute {
id: number;
name: string;
label?: string;
type: number;
typeValue?: string;
defaultType?: { id: number; name: string };
referenceObjectTypeId?: number;
referenceObjectType?: { id: number; name: string };
referenceType?: { id: number; name: string };
minimumCardinality?: number;
maximumCardinality?: number;
editable?: boolean;
hidden?: boolean;
system?: boolean;
options?: string;
description?: string;
}
interface GeneratedAttribute {
jiraId: number;
name: string;
fieldName: string;
type: 'text' | 'integer' | 'float' | 'boolean' | 'date' | 'datetime' | 'select' | 'reference' | 'url' | 'email' | 'textarea' | 'user' | 'status' | 'unknown';
isMultiple: boolean;
isEditable: boolean;
isRequired: boolean;
isSystem: boolean;
referenceTypeId?: number;
referenceTypeName?: string;
description?: string;
}
interface GeneratedObjectType {
jiraTypeId: number;
name: string;
typeName: string;
syncPriority: number;
objectCount: number;
attributes: GeneratedAttribute[];
}
// =============================================================================
// Jira Type Mapping
// =============================================================================
// Jira attribute type mappings (based on Jira Insight/Assets API)
// See: https://docs.atlassian.com/jira-servicemanagement-docs/REST/5.x/insight/1.0/objecttypeattribute
const JIRA_TYPE_MAP: Record<number, GeneratedAttribute['type']> = {
0: 'text', // Default/Text
1: 'integer', // Integer
2: 'boolean', // Boolean
3: 'float', // Double/Float
4: 'date', // Date
5: 'datetime', // DateTime
6: 'url', // URL
7: 'email', // Email
8: 'textarea', // Textarea
9: 'select', // Select
10: 'reference', // Reference (Object)
11: 'user', // User
12: 'reference', // Confluence (treated as reference)
13: 'reference', // Group (treated as reference)
14: 'reference', // Version (treated as reference)
15: 'reference', // Project (treated as reference)
16: 'status', // Status
};
// Priority types - these sync first as they are reference data
const PRIORITY_TYPE_NAMES = new Set([
'Application Component',
'Server',
'Flows',
]);
// Reference data types - these sync with lower priority
const REFERENCE_TYPE_PATTERNS = [
/Factor$/,
/Model$/,
/Type$/,
/Category$/,
/Importance$/,
/Analyse$/,
/Organisation$/,
/Function$/,
];
// =============================================================================
// Jira Schema Fetcher
// =============================================================================
class JiraSchemaFetcher {
private baseUrl: string;
private headers: Record<string, string>;
constructor(host: string, pat: string) {
this.baseUrl = `${host}/rest/insight/1.0`;
this.headers = {
'Authorization': `Bearer ${pat}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
};
}
/**
* Fetch schema info
*/
async fetchSchema(schemaId: string): Promise<JiraObjectSchema | null> {
try {
const response = await fetch(`${this.baseUrl}/objectschema/${schemaId}`, {
headers: this.headers,
});
if (!response.ok) {
console.error(`Failed to fetch schema ${schemaId}: ${response.status} ${response.statusText}`);
const text = await response.text();
console.error(`Response: ${text}`);
return null;
}
return await response.json();
} catch (error) {
console.error(`Error fetching schema ${schemaId}:`, error);
return null;
}
}
/**
* Fetch ALL object types in the schema
*/
async fetchAllObjectTypes(schemaId: string): Promise<JiraObjectType[]> {
try {
// Try the objecttypes/flat endpoint first (returns all types in flat structure)
let response = await fetch(`${this.baseUrl}/objectschema/${schemaId}/objecttypes/flat`, {
headers: this.headers,
});
if (!response.ok) {
// Fallback to regular objecttypes endpoint
response = await fetch(`${this.baseUrl}/objectschema/${schemaId}/objecttypes`, {
headers: this.headers,
});
}
if (!response.ok) {
console.error(`Failed to fetch object types: ${response.status} ${response.statusText}`);
const text = await response.text();
console.error(`Response: ${text}`);
return [];
}
const result = await response.json();
// Handle both array and object responses
if (Array.isArray(result)) {
return result;
} else if (result.objectTypes) {
return result.objectTypes;
}
return [];
} catch (error) {
console.error(`Error fetching object types:`, error);
return [];
}
}
/**
* Fetch attributes for a specific object type
*/
async fetchAttributes(typeId: number): Promise<JiraAttribute[]> {
try {
const response = await fetch(`${this.baseUrl}/objecttype/${typeId}/attributes`, {
headers: this.headers,
});
if (!response.ok) {
console.error(`Failed to fetch attributes for type ${typeId}: ${response.status}`);
return [];
}
return await response.json();
} catch (error) {
console.error(`Error fetching attributes for type ${typeId}:`, error);
return [];
}
}
/**
* Test the connection
*/
async testConnection(): Promise<boolean> {
try {
const response = await fetch(`${this.baseUrl}/objectschema/list`, {
headers: this.headers,
});
return response.ok;
} catch {
return false;
}
}
}
// =============================================================================
// Utility Functions
// =============================================================================
/**
* Convert a string to camelCase while preserving existing casing patterns
* E.g., "Application Function" -> "applicationFunction"
* "ICT Governance Model" -> "ictGovernanceModel"
* "ApplicationFunction" -> "applicationFunction"
*/
function toCamelCase(str: string): string {
// First split on spaces and special chars
const words = str
.replace(/[^a-zA-Z0-9\s]/g, ' ')
.split(/\s+/)
.filter(w => w.length > 0);
if (words.length === 0) return '';
// If it's a single word that's already camelCase or PascalCase, just lowercase first char
if (words.length === 1) {
const word = words[0];
return word.charAt(0).toLowerCase() + word.slice(1);
}
// Multiple words - first word lowercase, rest capitalize first letter
return words
.map((word, index) => {
if (index === 0) {
// First word: if all uppercase (acronym), lowercase it, otherwise just lowercase first char
if (word === word.toUpperCase() && word.length > 1) {
return word.toLowerCase();
}
return word.charAt(0).toLowerCase() + word.slice(1);
}
// Other words: capitalize first letter, keep rest as-is
return word.charAt(0).toUpperCase() + word.slice(1);
})
.join('');
}
/**
* Convert a string to PascalCase while preserving existing casing patterns
* E.g., "Application Function" -> "ApplicationFunction"
* "ICT Governance Model" -> "IctGovernanceModel"
* "applicationFunction" -> "ApplicationFunction"
*/
function toPascalCase(str: string): string {
// First split on spaces and special chars
const words = str
.replace(/[^a-zA-Z0-9\s]/g, ' ')
.split(/\s+/)
.filter(w => w.length > 0);
if (words.length === 0) return '';
// If it's a single word, just capitalize first letter
if (words.length === 1) {
const word = words[0];
return word.charAt(0).toUpperCase() + word.slice(1);
}
// Multiple words - capitalize first letter of each
return words
.map(word => {
// If all uppercase (acronym) and first word, just capitalize first letter
if (word === word.toUpperCase() && word.length > 1) {
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
}
return word.charAt(0).toUpperCase() + word.slice(1);
})
.join('');
}
function mapJiraType(typeId: number): GeneratedAttribute['type'] {
return JIRA_TYPE_MAP[typeId] || 'unknown';
}
function determineSyncPriority(typeName: string, objectCount: number): number {
// Application Component and related main types first
if (PRIORITY_TYPE_NAMES.has(typeName)) {
return 1;
}
// Reference data types last
for (const pattern of REFERENCE_TYPE_PATTERNS) {
if (pattern.test(typeName)) {
return 10;
}
}
// Medium priority for types with more objects
if (objectCount > 100) {
return 2;
}
if (objectCount > 10) {
return 5;
}
return 8;
}
function parseAttribute(
attr: JiraAttribute,
allTypeConfigs: Map<number, { name: string; typeName: string }>
): GeneratedAttribute {
const typeId = attr.type || attr.defaultType?.id || 0;
let type = mapJiraType(typeId);
const isMultiple = (attr.maximumCardinality ?? 1) > 1 || attr.maximumCardinality === -1;
const isEditable = attr.editable !== false && !attr.hidden;
const isRequired = (attr.minimumCardinality ?? 0) > 0;
const isSystem = attr.system === true;
// CRITICAL: Jira sometimes returns type=1 (integer) for reference attributes!
// The presence of referenceObjectTypeId is the true indicator of a reference type.
const refTypeId = attr.referenceObjectTypeId || attr.referenceObjectType?.id || attr.referenceType?.id;
if (refTypeId) {
type = 'reference';
}
const result: GeneratedAttribute = {
jiraId: attr.id,
name: attr.name,
fieldName: toCamelCase(attr.name),
type,
isMultiple,
isEditable,
isRequired,
isSystem,
description: attr.description,
};
// Handle reference types - add reference metadata
if (type === 'reference' && refTypeId) {
result.referenceTypeId = refTypeId;
const refConfig = allTypeConfigs.get(refTypeId);
result.referenceTypeName = refConfig?.typeName ||
attr.referenceObjectType?.name ||
attr.referenceType?.name ||
`Type${refTypeId}`;
}
return result;
}
// =============================================================================
// Code Generation Functions
// =============================================================================
function generateTypeScriptType(attrType: GeneratedAttribute['type'], isMultiple: boolean, isReference: boolean): string {
let tsType: string;
if (isReference) {
tsType = 'ObjectReference';
} else {
switch (attrType) {
case 'text':
case 'textarea':
case 'url':
case 'email':
case 'select':
case 'user':
case 'status':
tsType = 'string';
break;
case 'integer':
case 'float':
tsType = 'number';
break;
case 'boolean':
tsType = 'boolean';
break;
case 'date':
case 'datetime':
tsType = 'string'; // ISO date string
break;
default:
tsType = 'unknown';
}
}
if (isMultiple) {
return `${tsType}[]`;
}
return `${tsType} | null`;
}
function generateTypesFile(objectTypes: GeneratedObjectType[], generatedAt: Date): string {
const lines: string[] = [
'// AUTO-GENERATED FILE - DO NOT EDIT MANUALLY',
'// Generated from Jira Assets Schema via REST API',
`// Generated at: ${generatedAt.toISOString()}`,
'//',
'// Re-generate with: npm run generate-schema',
'',
'// =============================================================================',
'// Base Types',
'// =============================================================================',
'',
'/** Reference to another CMDB object */',
'export interface ObjectReference {',
' objectId: string;',
' objectKey: string;',
' label: string;',
' // Optional enriched data from referenced object',
' factor?: number;',
'}',
'',
'/** Base interface for all CMDB objects */',
'export interface BaseCMDBObject {',
' id: string;',
' objectKey: string;',
' label: string;',
' _objectType: string;',
' _jiraUpdatedAt: string;',
' _jiraCreatedAt: string;',
'}',
'',
'// =============================================================================',
'// Object Type Interfaces',
'// =============================================================================',
'',
];
for (const objType of objectTypes) {
lines.push(`/** ${objType.name} (Jira Type ID: ${objType.jiraTypeId}, ${objType.objectCount} objects) */`);
lines.push(`export interface ${objType.typeName} extends BaseCMDBObject {`);
lines.push(` _objectType: '${objType.typeName}';`);
lines.push('');
// Group attributes by type
const scalarAttrs = objType.attributes.filter(a => a.type !== 'reference');
const refAttrs = objType.attributes.filter(a => a.type === 'reference');
if (scalarAttrs.length > 0) {
lines.push(' // Scalar attributes');
for (const attr of scalarAttrs) {
const tsType = generateTypeScriptType(attr.type, attr.isMultiple, false);
const comment = attr.description ? ` // ${attr.description}` : '';
lines.push(` ${attr.fieldName}: ${tsType};${comment}`);
}
lines.push('');
}
if (refAttrs.length > 0) {
lines.push(' // Reference attributes');
for (const attr of refAttrs) {
const tsType = generateTypeScriptType(attr.type, attr.isMultiple, true);
const comment = attr.referenceTypeName ? ` // -> ${attr.referenceTypeName}` : '';
lines.push(` ${attr.fieldName}: ${tsType};${comment}`);
}
lines.push('');
}
lines.push('}');
lines.push('');
}
// Generate union type
lines.push('// =============================================================================');
lines.push('// Union Types');
lines.push('// =============================================================================');
lines.push('');
lines.push('/** Union of all CMDB object types */');
lines.push('export type CMDBObject =');
for (let i = 0; i < objectTypes.length; i++) {
const suffix = i < objectTypes.length - 1 ? '' : ';';
lines.push(` | ${objectTypes[i].typeName}${suffix}`);
}
lines.push('');
// Generate type name literal union
lines.push('/** All valid object type names */');
lines.push('export type CMDBObjectTypeName =');
for (let i = 0; i < objectTypes.length; i++) {
const suffix = i < objectTypes.length - 1 ? '' : ';';
lines.push(` | '${objectTypes[i].typeName}'${suffix}`);
}
lines.push('');
// Generate type guards
lines.push('// =============================================================================');
lines.push('// Type Guards');
lines.push('// =============================================================================');
lines.push('');
for (const objType of objectTypes) {
lines.push(`export function is${objType.typeName}(obj: CMDBObject): obj is ${objType.typeName} {`);
lines.push(` return obj._objectType === '${objType.typeName}';`);
lines.push('}');
lines.push('');
}
return lines.join('\n');
}
function generateSchemaFile(objectTypes: GeneratedObjectType[], generatedAt: Date): string {
const lines: string[] = [
'// AUTO-GENERATED FILE - DO NOT EDIT MANUALLY',
'// Generated from Jira Assets Schema via REST API',
`// Generated at: ${generatedAt.toISOString()}`,
'//',
'// Re-generate with: npm run generate-schema',
'',
'// =============================================================================',
'// Schema Type Definitions',
'// =============================================================================',
'',
'export interface AttributeDefinition {',
' jiraId: number;',
' name: string;',
' fieldName: string;',
" type: 'text' | 'integer' | 'float' | 'boolean' | 'date' | 'datetime' | 'select' | 'reference' | 'url' | 'email' | 'textarea' | 'user' | 'status' | 'unknown';",
' isMultiple: boolean;',
' isEditable: boolean;',
' isRequired: boolean;',
' isSystem: boolean;',
' referenceTypeId?: number;',
' referenceTypeName?: string;',
' description?: string;',
'}',
'',
'export interface ObjectTypeDefinition {',
' jiraTypeId: number;',
' name: string;',
' typeName: string;',
' syncPriority: number;',
' objectCount: number;',
' attributes: AttributeDefinition[];',
'}',
'',
'// =============================================================================',
'// Schema Metadata',
'// =============================================================================',
'',
`export const SCHEMA_GENERATED_AT = '${generatedAt.toISOString()}';`,
`export const SCHEMA_OBJECT_TYPE_COUNT = ${objectTypes.length};`,
`export const SCHEMA_TOTAL_ATTRIBUTES = ${objectTypes.reduce((sum, ot) => sum + ot.attributes.length, 0)};`,
'',
'// =============================================================================',
'// Object Type Definitions',
'// =============================================================================',
'',
'export const OBJECT_TYPES: Record<string, ObjectTypeDefinition> = {',
];
for (let i = 0; i < objectTypes.length; i++) {
const objType = objectTypes[i];
const comma = i < objectTypes.length - 1 ? ',' : '';
lines.push(` '${objType.typeName}': {`);
lines.push(` jiraTypeId: ${objType.jiraTypeId},`);
lines.push(` name: '${objType.name.replace(/'/g, "\\'")}',`);
lines.push(` typeName: '${objType.typeName}',`);
lines.push(` syncPriority: ${objType.syncPriority},`);
lines.push(` objectCount: ${objType.objectCount},`);
lines.push(' attributes: [');
for (let j = 0; j < objType.attributes.length; j++) {
const attr = objType.attributes[j];
const attrComma = j < objType.attributes.length - 1 ? ',' : '';
let attrLine = ` { jiraId: ${attr.jiraId}, name: '${attr.name.replace(/'/g, "\\'")}', fieldName: '${attr.fieldName}', type: '${attr.type}', isMultiple: ${attr.isMultiple}, isEditable: ${attr.isEditable}, isRequired: ${attr.isRequired}, isSystem: ${attr.isSystem}`;
if (attr.referenceTypeId) {
attrLine += `, referenceTypeId: ${attr.referenceTypeId}, referenceTypeName: '${attr.referenceTypeName}'`;
}
if (attr.description) {
attrLine += `, description: '${attr.description.replace(/'/g, "\\'").replace(/\n/g, ' ')}'`;
}
attrLine += ` }${attrComma}`;
lines.push(attrLine);
}
lines.push(' ],');
lines.push(` }${comma}`);
}
lines.push('};');
lines.push('');
// Generate lookup maps
lines.push('// =============================================================================');
lines.push('// Lookup Maps');
lines.push('// =============================================================================');
lines.push('');
// Type ID to name map
lines.push('/** Map from Jira Type ID to TypeScript type name */');
lines.push('export const TYPE_ID_TO_NAME: Record<number, string> = {');
for (const objType of objectTypes) {
lines.push(` ${objType.jiraTypeId}: '${objType.typeName}',`);
}
lines.push('};');
lines.push('');
// Type name to ID map
lines.push('/** Map from TypeScript type name to Jira Type ID */');
lines.push('export const TYPE_NAME_TO_ID: Record<string, number> = {');
for (const objType of objectTypes) {
lines.push(` '${objType.typeName}': ${objType.jiraTypeId},`);
}
lines.push('};');
lines.push('');
// Jira name to TypeScript name map
lines.push('/** Map from Jira object type name to TypeScript type name */');
lines.push('export const JIRA_NAME_TO_TYPE: Record<string, string> = {');
for (const objType of objectTypes) {
lines.push(` '${objType.name.replace(/'/g, "\\'")}': '${objType.typeName}',`);
}
lines.push('};');
lines.push('');
// Helper functions
lines.push('// =============================================================================');
lines.push('// Helper Functions');
lines.push('// =============================================================================');
lines.push('');
lines.push('/** Get attribute definition by type and field name */');
lines.push('export function getAttributeDefinition(typeName: string, fieldName: string): AttributeDefinition | undefined {');
lines.push(' const objectType = OBJECT_TYPES[typeName];');
lines.push(' if (!objectType) return undefined;');
lines.push(' return objectType.attributes.find(a => a.fieldName === fieldName);');
lines.push('}');
lines.push('');
lines.push('/** Get attribute definition by type and Jira attribute ID */');
lines.push('export function getAttributeById(typeName: string, jiraId: number): AttributeDefinition | undefined {');
lines.push(' const objectType = OBJECT_TYPES[typeName];');
lines.push(' if (!objectType) return undefined;');
lines.push(' return objectType.attributes.find(a => a.jiraId === jiraId);');
lines.push('}');
lines.push('');
lines.push('/** Get attribute definition by type and Jira attribute name */');
lines.push('export function getAttributeByName(typeName: string, attrName: string): AttributeDefinition | undefined {');
lines.push(' const objectType = OBJECT_TYPES[typeName];');
lines.push(' if (!objectType) return undefined;');
lines.push(' return objectType.attributes.find(a => a.name === attrName);');
lines.push('}');
lines.push('');
lines.push('/** Get attribute Jira ID by type and attribute name - throws if not found */');
lines.push('export function getAttributeId(typeName: string, attrName: string): number {');
lines.push(' const attr = getAttributeByName(typeName, attrName);');
lines.push(' if (!attr) {');
lines.push(' throw new Error(`Attribute "${attrName}" not found on type "${typeName}"`);');
lines.push(' }');
lines.push(' return attr.jiraId;');
lines.push('}');
lines.push('');
lines.push('/** Get all reference attributes for a type */');
lines.push('export function getReferenceAttributes(typeName: string): AttributeDefinition[] {');
lines.push(' const objectType = OBJECT_TYPES[typeName];');
lines.push(' if (!objectType) return [];');
lines.push(" return objectType.attributes.filter(a => a.type === 'reference');");
lines.push('}');
lines.push('');
lines.push('/** Get all object types sorted by sync priority */');
lines.push('export function getObjectTypesBySyncPriority(): ObjectTypeDefinition[] {');
lines.push(' return Object.values(OBJECT_TYPES).sort((a, b) => a.syncPriority - b.syncPriority);');
lines.push('}');
lines.push('');
return lines.join('\n');
}
function generateDatabaseSchema(generatedAt: Date): string {
const lines: string[] = [
'-- AUTO-GENERATED FILE - DO NOT EDIT MANUALLY',
'-- Generated from Jira Assets Schema via REST API',
`-- Generated at: ${generatedAt.toISOString()}`,
'--',
'-- Re-generate with: npm run generate-schema',
'',
'-- =============================================================================',
'-- Core Tables',
'-- =============================================================================',
'',
'-- Cached CMDB objects (all types stored in single table with JSON data)',
'CREATE TABLE IF NOT EXISTS cached_objects (',
' id TEXT PRIMARY KEY,',
' object_key TEXT NOT NULL UNIQUE,',
' object_type TEXT NOT NULL,',
' label TEXT NOT NULL,',
' data JSON NOT NULL,',
' jira_updated_at TEXT,',
' jira_created_at TEXT,',
' cached_at TEXT NOT NULL',
');',
'',
'-- Object relations (references between objects)',
'CREATE TABLE IF NOT EXISTS object_relations (',
' id INTEGER PRIMARY KEY AUTOINCREMENT,',
' source_id TEXT NOT NULL,',
' target_id TEXT NOT NULL,',
' attribute_name TEXT NOT NULL,',
' source_type TEXT NOT NULL,',
' target_type TEXT NOT NULL,',
' UNIQUE(source_id, target_id, attribute_name)',
');',
'',
'-- Sync metadata (tracks sync state)',
'CREATE TABLE IF NOT EXISTS sync_metadata (',
' key TEXT PRIMARY KEY,',
' value TEXT NOT NULL,',
' updated_at TEXT NOT NULL',
');',
'',
'-- =============================================================================',
'-- Indices for Performance',
'-- =============================================================================',
'',
'CREATE INDEX IF NOT EXISTS idx_objects_type ON cached_objects(object_type);',
'CREATE INDEX IF NOT EXISTS idx_objects_key ON cached_objects(object_key);',
'CREATE INDEX IF NOT EXISTS idx_objects_updated ON cached_objects(jira_updated_at);',
'CREATE INDEX IF NOT EXISTS idx_objects_label ON cached_objects(label);',
'',
'CREATE INDEX IF NOT EXISTS idx_relations_source ON object_relations(source_id);',
'CREATE INDEX IF NOT EXISTS idx_relations_target ON object_relations(target_id);',
'CREATE INDEX IF NOT EXISTS idx_relations_source_type ON object_relations(source_type);',
'CREATE INDEX IF NOT EXISTS idx_relations_target_type ON object_relations(target_type);',
'CREATE INDEX IF NOT EXISTS idx_relations_attr ON object_relations(attribute_name);',
'',
];
return lines.join('\n');
}
// =============================================================================
// Main
// =============================================================================
async function main() {
const generatedAt = new Date();
console.log('');
console.log('╔════════════════════════════════════════════════════════════════╗');
console.log('║ CMDB Schema Generator - Jira Assets API ║');
console.log('╚════════════════════════════════════════════════════════════════╝');
console.log('');
// Validate configuration
if (!JIRA_HOST) {
console.error('❌ ERROR: JIRA_HOST environment variable is required');
console.error(' Set this in your .env file: JIRA_HOST=https://jira.your-domain.com');
process.exit(1);
}
if (!JIRA_PAT) {
console.error('❌ ERROR: JIRA_PAT environment variable is required');
console.error(' Set this in your .env file: JIRA_PAT=your-personal-access-token');
process.exit(1);
}
if (!JIRA_SCHEMA_ID) {
console.error('❌ ERROR: JIRA_SCHEMA_ID environment variable is required');
console.error(' Set this in your .env file: JIRA_SCHEMA_ID=6');
process.exit(1);
}
if (envLoaded) {
console.log(`🔧 Environment: ${envLoaded}`);
}
console.log(`📡 Jira Host: ${JIRA_HOST}`);
console.log(`📋 Schema ID: ${JIRA_SCHEMA_ID}`);
console.log(`📁 Output Dir: ${OUTPUT_DIR}`);
console.log('');
// Ensure output directory exists
if (!fs.existsSync(OUTPUT_DIR)) {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
console.log(`📁 Created output directory: ${OUTPUT_DIR}`);
}
const fetcher = new JiraSchemaFetcher(JIRA_HOST, JIRA_PAT);
// Test connection
console.log('🔌 Testing connection to Jira Assets API...');
const connected = await fetcher.testConnection();
if (!connected) {
console.error('❌ Failed to connect to Jira Assets API');
console.error(' Please check your JIRA_HOST and JIRA_PAT settings');
process.exit(1);
}
console.log('✅ Connection successful');
console.log('');
// Fetch schema info
console.log('📋 Fetching schema information...');
const schema = await fetcher.fetchSchema(JIRA_SCHEMA_ID);
if (!schema) {
console.error(`❌ Failed to fetch schema ${JIRA_SCHEMA_ID}`);
process.exit(1);
}
console.log(` Schema: ${schema.name} (${schema.objectSchemaKey})`);
console.log(` Total objects: ${schema.objectCount || 'unknown'}`);
console.log('');
// Fetch ALL object types from the schema
console.log('📦 Fetching all object types from schema...');
const allObjectTypes = await fetcher.fetchAllObjectTypes(JIRA_SCHEMA_ID);
if (allObjectTypes.length === 0) {
console.error('❌ No object types found in schema');
process.exit(1);
}
console.log(` Found ${allObjectTypes.length} object types`);
console.log('');
// Build a map of all type IDs to names for reference resolution
const typeConfigs = new Map<number, { name: string; typeName: string }>();
for (const ot of allObjectTypes) {
typeConfigs.set(ot.id, {
name: ot.name,
typeName: toPascalCase(ot.name),
});
}
// Fetch attributes for each object type
console.log('🔍 Fetching attributes for each object type:');
console.log('');
const generatedTypes: GeneratedObjectType[] = [];
let totalAttributes = 0;
for (const objType of allObjectTypes) {
const typeName = toPascalCase(objType.name);
process.stdout.write(` ${objType.name.padEnd(50)} `);
const attributes = await fetcher.fetchAttributes(objType.id);
if (attributes.length === 0) {
console.log('⚠️ (no attributes)');
continue;
}
const parsedAttributes = attributes.map(attr => parseAttribute(attr, typeConfigs));
const syncPriority = determineSyncPriority(objType.name, objType.objectCount || 0);
generatedTypes.push({
jiraTypeId: objType.id,
name: objType.name,
typeName,
syncPriority,
objectCount: objType.objectCount || 0,
attributes: parsedAttributes,
});
totalAttributes += attributes.length;
console.log(`${attributes.length} attributes`);
}
console.log('');
console.log(`📊 Summary: ${generatedTypes.length} types, ${totalAttributes} attributes total`);
console.log('');
// Sort by sync priority
generatedTypes.sort((a, b) => a.syncPriority - b.syncPriority);
// Generate files
console.log('📝 Generating output files:');
console.log('');
// 1. TypeScript types
const typesContent = generateTypesFile(generatedTypes, generatedAt);
const typesPath = path.join(OUTPUT_DIR, 'jira-types.ts');
fs.writeFileSync(typesPath, typesContent, 'utf-8');
console.log(`${typesPath}`);
// 2. Schema metadata
const schemaContent = generateSchemaFile(generatedTypes, generatedAt);
const schemaPath = path.join(OUTPUT_DIR, 'jira-schema.ts');
fs.writeFileSync(schemaPath, schemaContent, 'utf-8');
console.log(`${schemaPath}`);
// 3. Database DDL
const dbContent = generateDatabaseSchema(generatedAt);
const dbPath = path.join(OUTPUT_DIR, 'db-schema.sql');
fs.writeFileSync(dbPath, dbContent, 'utf-8');
console.log(`${dbPath}`);
console.log('');
console.log('╔════════════════════════════════════════════════════════════════╗');
console.log('║ Schema generation complete! ║');
console.log('╚════════════════════════════════════════════════════════════════╝');
console.log('');
console.log(`Generated at: ${generatedAt.toISOString()}`);
console.log(`Object types: ${generatedTypes.length}`);
console.log(`Attributes: ${totalAttributes}`);
console.log('');
console.log('Next steps:');
console.log(' 1. Review the generated files in src/generated/');
console.log(' 2. Rebuild the application: npm run build');
console.log(' 3. Restart the server to pick up the new schema');
console.log('');
}
// Run
main().catch(error => {
console.error('');
console.error('❌ Schema generation failed:', error);
console.error('');
process.exit(1);
});

View File

@@ -1,17 +1,42 @@
import dotenv from 'dotenv'; import dotenv from 'dotenv';
import path from 'path'; import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
// Load .env from project root // Get __dirname equivalent for ES modules
dotenv.config({ path: path.resolve(__dirname, '../../../.env') }); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Try multiple possible .env locations (handles both tsx watch and compiled dist)
const possibleEnvPaths = [
path.resolve(process.cwd(), '.env'), // Project root from cwd
path.resolve(__dirname, '../../../.env'), // From src/config/ to project root
path.resolve(__dirname, '../../../../.env'), // From dist/config/ to project root
];
for (const envPath of possibleEnvPaths) {
if (fs.existsSync(envPath)) {
dotenv.config({ path: envPath });
console.log(`Environment loaded from: ${envPath}`);
break;
}
}
// Authentication method type
export type JiraAuthMethod = 'pat' | 'oauth';
interface Config { interface Config {
// Jira Assets // Jira Assets
jiraHost: string; jiraHost: string;
jiraPat: string;
jiraSchemaId: string; jiraSchemaId: string;
// Jira OAuth 2.0 Configuration // Jira Authentication Method ('pat' or 'oauth')
jiraOAuthEnabled: boolean; jiraAuthMethod: JiraAuthMethod;
// Jira Personal Access Token (used when jiraAuthMethod = 'pat')
jiraPat: string;
// Jira OAuth 2.0 Configuration (used when jiraAuthMethod = 'oauth')
jiraOAuthClientId: string; jiraOAuthClientId: string;
jiraOAuthClientSecret: string; jiraOAuthClientSecret: string;
jiraOAuthCallbackUrl: string; jiraOAuthCallbackUrl: string;
@@ -20,38 +45,6 @@ interface Config {
// Session Configuration // Session Configuration
sessionSecret: string; sessionSecret: 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 // AI API Keys
anthropicApiKey: string; anthropicApiKey: string;
openaiApiKey: string; openaiApiKey: string;
@@ -72,26 +65,44 @@ interface Config {
jiraApiBatchSize: number; 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 { function getOptionalEnvVar(name: string, defaultValue: string = ''): string {
return process.env[name] || defaultValue; return process.env[name] || defaultValue;
} }
// Helper to determine auth method with backward compatibility
function getJiraAuthMethod(): JiraAuthMethod {
// Check new JIRA_AUTH_METHOD first
const authMethod = getOptionalEnvVar('JIRA_AUTH_METHOD', '').toLowerCase();
if (authMethod === 'oauth') return 'oauth';
if (authMethod === 'pat') return 'pat';
// Backward compatibility: check JIRA_OAUTH_ENABLED
const oauthEnabled = getOptionalEnvVar('JIRA_OAUTH_ENABLED', 'false').toLowerCase() === 'true';
if (oauthEnabled) return 'oauth';
// Default to 'pat' if JIRA_PAT is set, otherwise 'oauth' if OAuth credentials exist
const hasPat = !!getOptionalEnvVar('JIRA_PAT');
const hasOAuthCredentials = !!getOptionalEnvVar('JIRA_OAUTH_CLIENT_ID') && !!getOptionalEnvVar('JIRA_OAUTH_CLIENT_SECRET');
if (hasPat) return 'pat';
if (hasOAuthCredentials) return 'oauth';
// Default to 'pat' (will show warning during validation)
return 'pat';
}
export const config: Config = { export const config: Config = {
// Jira Assets // Jira Assets
jiraHost: getOptionalEnvVar('JIRA_HOST', 'https://jira.zuyderland.nl'), jiraHost: getOptionalEnvVar('JIRA_HOST', 'https://jira.zuyderland.nl'),
jiraPat: getOptionalEnvVar('JIRA_PAT'),
jiraSchemaId: getOptionalEnvVar('JIRA_SCHEMA_ID'), jiraSchemaId: getOptionalEnvVar('JIRA_SCHEMA_ID'),
// Jira OAuth 2.0 Configuration // Jira Authentication Method
jiraOAuthEnabled: getOptionalEnvVar('JIRA_OAUTH_ENABLED', 'false').toLowerCase() === 'true', jiraAuthMethod: getJiraAuthMethod(),
// Jira Personal Access Token (for PAT authentication)
jiraPat: getOptionalEnvVar('JIRA_PAT'),
// Jira OAuth 2.0 Configuration (for OAuth authentication)
jiraOAuthClientId: getOptionalEnvVar('JIRA_OAUTH_CLIENT_ID'), jiraOAuthClientId: getOptionalEnvVar('JIRA_OAUTH_CLIENT_ID'),
jiraOAuthClientSecret: getOptionalEnvVar('JIRA_OAUTH_CLIENT_SECRET'), jiraOAuthClientSecret: getOptionalEnvVar('JIRA_OAUTH_CLIENT_SECRET'),
jiraOAuthCallbackUrl: getOptionalEnvVar('JIRA_OAUTH_CALLBACK_URL', 'http://localhost:3001/api/auth/callback'), jiraOAuthCallbackUrl: getOptionalEnvVar('JIRA_OAUTH_CALLBACK_URL', 'http://localhost:3001/api/auth/callback'),
@@ -100,38 +111,6 @@ export const config: Config = {
// Session Configuration // Session Configuration
sessionSecret: getOptionalEnvVar('SESSION_SECRET', 'change-this-secret-in-production'), sessionSecret: getOptionalEnvVar('SESSION_SECRET', 'change-this-secret-in-production'),
// 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 // AI API Keys
anthropicApiKey: getOptionalEnvVar('ANTHROPIC_API_KEY'), anthropicApiKey: getOptionalEnvVar('ANTHROPIC_API_KEY'),
openaiApiKey: getOptionalEnvVar('OPENAI_API_KEY'), openaiApiKey: getOptionalEnvVar('OPENAI_API_KEY'),
@@ -154,10 +133,34 @@ export const config: Config = {
export function validateConfig(): void { export function validateConfig(): void {
const missingVars: string[] = []; const missingVars: string[] = [];
const warnings: string[] = [];
if (!config.jiraPat) missingVars.push('JIRA_PAT'); // Validate authentication configuration based on selected method
console.log(`Jira Authentication Method: ${config.jiraAuthMethod.toUpperCase()}`);
if (config.jiraAuthMethod === 'pat') {
if (!config.jiraPat) {
missingVars.push('JIRA_PAT (required for PAT authentication)');
}
} else if (config.jiraAuthMethod === 'oauth') {
if (!config.jiraOAuthClientId) {
missingVars.push('JIRA_OAUTH_CLIENT_ID (required for OAuth authentication)');
}
if (!config.jiraOAuthClientSecret) {
missingVars.push('JIRA_OAUTH_CLIENT_SECRET (required for OAuth authentication)');
}
if (!config.sessionSecret || config.sessionSecret === 'change-this-secret-in-production') {
warnings.push('SESSION_SECRET should be set to a secure random value for OAuth sessions');
}
}
// General required config
if (!config.jiraSchemaId) missingVars.push('JIRA_SCHEMA_ID'); if (!config.jiraSchemaId) missingVars.push('JIRA_SCHEMA_ID');
if (!config.anthropicApiKey) missingVars.push('ANTHROPIC_API_KEY'); if (!config.anthropicApiKey) warnings.push('ANTHROPIC_API_KEY not set - AI classification disabled');
if (warnings.length > 0) {
warnings.forEach(w => console.warn(`Warning: ${w}`));
}
if (missingVars.length > 0) { if (missingVars.length > 0) {
console.warn(`Warning: Missing environment variables: ${missingVars.join(', ')}`); console.warn(`Warning: Missing environment variables: ${missingVars.join(', ')}`);

View File

@@ -0,0 +1,54 @@
-- AUTO-GENERATED FILE - DO NOT EDIT MANUALLY
-- Generated from Jira Assets Schema via REST API
-- Generated at: 2026-01-09T02:12:50.973Z
--
-- Re-generate with: npm run generate-schema
-- =============================================================================
-- Core Tables
-- =============================================================================
-- Cached CMDB objects (all types stored in single table with JSON data)
CREATE TABLE IF NOT EXISTS cached_objects (
id TEXT PRIMARY KEY,
object_key TEXT NOT NULL UNIQUE,
object_type TEXT NOT NULL,
label TEXT NOT NULL,
data JSON NOT NULL,
jira_updated_at TEXT,
jira_created_at TEXT,
cached_at TEXT NOT NULL
);
-- Object relations (references between objects)
CREATE TABLE IF NOT EXISTS object_relations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_id TEXT NOT NULL,
target_id TEXT NOT NULL,
attribute_name TEXT NOT NULL,
source_type TEXT NOT NULL,
target_type TEXT NOT NULL,
UNIQUE(source_id, target_id, attribute_name)
);
-- Sync metadata (tracks sync state)
CREATE TABLE IF NOT EXISTS sync_metadata (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL
);
-- =============================================================================
-- Indices for Performance
-- =============================================================================
CREATE INDEX IF NOT EXISTS idx_objects_type ON cached_objects(object_type);
CREATE INDEX IF NOT EXISTS idx_objects_key ON cached_objects(object_key);
CREATE INDEX IF NOT EXISTS idx_objects_updated ON cached_objects(jira_updated_at);
CREATE INDEX IF NOT EXISTS idx_objects_label ON cached_objects(label);
CREATE INDEX IF NOT EXISTS idx_relations_source ON object_relations(source_id);
CREATE INDEX IF NOT EXISTS idx_relations_target ON object_relations(target_id);
CREATE INDEX IF NOT EXISTS idx_relations_source_type ON object_relations(source_type);
CREATE INDEX IF NOT EXISTS idx_relations_target_type ON object_relations(target_type);
CREATE INDEX IF NOT EXISTS idx_relations_attr ON object_relations(attribute_name);

View File

@@ -0,0 +1,894 @@
// AUTO-GENERATED FILE - DO NOT EDIT MANUALLY
// Generated from Jira Assets Schema via REST API
// Generated at: 2026-01-09T02:12:50.973Z
//
// Re-generate with: npm run generate-schema
// =============================================================================
// Schema Type Definitions
// =============================================================================
export interface AttributeDefinition {
jiraId: number;
name: string;
fieldName: string;
type: 'text' | 'integer' | 'float' | 'boolean' | 'date' | 'datetime' | 'select' | 'reference' | 'url' | 'email' | 'textarea' | 'user' | 'status' | 'unknown';
isMultiple: boolean;
isEditable: boolean;
isRequired: boolean;
isSystem: boolean;
referenceTypeId?: number;
referenceTypeName?: string;
description?: string;
}
export interface ObjectTypeDefinition {
jiraTypeId: number;
name: string;
typeName: string;
syncPriority: number;
objectCount: number;
attributes: AttributeDefinition[];
}
// =============================================================================
// Schema Metadata
// =============================================================================
export const SCHEMA_GENERATED_AT = '2026-01-09T02:12:50.973Z';
export const SCHEMA_OBJECT_TYPE_COUNT = 35;
export const SCHEMA_TOTAL_ATTRIBUTES = 365;
// =============================================================================
// Object Type Definitions
// =============================================================================
export const OBJECT_TYPES: Record<string, ObjectTypeDefinition> = {
'ApplicationComponent': {
jiraTypeId: 38,
name: 'Application Component',
typeName: 'ApplicationComponent',
syncPriority: 1,
objectCount: 596,
attributes: [
{ jiraId: 569, name: 'Reference', fieldName: 'reference', type: 'text', isMultiple: false, isEditable: false, isRequired: false, isSystem: false, description: 'Niet aanpassen. GUID - Enterprise Architect' },
{ jiraId: 341, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 425, name: 'SearchReference', fieldName: 'searchReference', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, description: 'Additionele zoekwoorden t.b.v. search' },
{ jiraId: 342, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'Unieke naam object' },
{ jiraId: 343, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 344, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 354, name: 'Description', fieldName: 'description', type: 'select', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, description: '* Application description' },
{ jiraId: 4538, name: 'Organisation', fieldName: 'organisation', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 390, referenceTypeName: 'Organisation' },
{ jiraId: 4666, name: 'ApplicationFunction', fieldName: 'applicationFunction', type: 'reference', isMultiple: true, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 403, referenceTypeName: 'ApplicationFunction' },
{ jiraId: 2416, name: 'Status', fieldName: 'status', type: 'email', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, description: 'Application Lifecycle Management' },
{ jiraId: 416, name: 'Confluence Space', fieldName: 'confluenceSpace', type: 'float', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 399, name: 'Business Importance', fieldName: 'businessImportance', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 44, referenceTypeName: 'BusinessImportance' },
{ jiraId: 4540, name: 'Zenya ID', fieldName: 'zenyaID', type: 'integer', isMultiple: false, isEditable: false, isRequired: false, isSystem: false },
{ jiraId: 4615, name: 'Zenya URL', fieldName: 'zenyaURL', type: 'email', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 368, name: 'Business Impact Analyse', fieldName: 'businessImpactAnalyse', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 41, referenceTypeName: 'BusinessImpactAnalyse' },
{ jiraId: 355, name: 'Application Component Hosting Type', fieldName: 'applicationComponentHostingType', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 39, referenceTypeName: 'HostingType' },
{ jiraId: 394, name: 'CustomDevelopment', fieldName: 'customDevelopment', type: 'boolean', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, description: 'Is er sprake van eigen programmatuur?' },
{ jiraId: 4927, name: 'Platform', fieldName: 'platform', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 38, referenceTypeName: 'ApplicationComponent' },
{ jiraId: 358, name: 'Referenced Application Component', fieldName: 'referencedApplicationComponent', type: 'reference', isMultiple: true, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 38, referenceTypeName: 'ApplicationComponent', description: 'Welk application component maakt onderdeel uit van een ander Application Component' },
{ jiraId: 359, name: 'Authentication Method', fieldName: 'authenticationMethod', type: 'reference', isMultiple: false, isEditable: false, isRequired: false, isSystem: false, referenceTypeId: 38, referenceTypeName: 'ApplicationComponent', description: '*HIDDEN* zie CMDB-488' },
{ jiraId: 362, name: 'Monitoring', fieldName: 'monitoring', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 38, referenceTypeName: 'ApplicationComponent', description: 'Cross reference naar Application Component' },
{ jiraId: 373, name: 'PII Data', fieldName: 'piiData', type: 'boolean', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, description: 'Maakt applicatie gebruik van Persoonlijk identificeerbare informatie?' },
{ jiraId: 374, name: 'Medical Data', fieldName: 'medicalData', type: 'boolean', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, description: 'Maakt de Application Component gebruik van medische data?' },
{ jiraId: 363, name: 'Supplier Product', fieldName: 'supplierProduct', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 37, referenceTypeName: 'Supplier', description: 'Wie is de leverancier van de Application Component?' },
{ jiraId: 364, name: 'Supplier Technical', fieldName: 'supplierTechnical', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 37, referenceTypeName: 'Supplier' },
{ jiraId: 365, name: 'Supplier Implementation', fieldName: 'supplierImplementation', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 37, referenceTypeName: 'Supplier', description: 'Wie is leverancier van de implementatie?' },
{ jiraId: 366, name: 'Supplier Consultancy', fieldName: 'supplierConsultancy', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 37, referenceTypeName: 'Supplier' },
{ jiraId: 2365, name: 'Business Owner', fieldName: 'businessOwner', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 243, referenceTypeName: 'Organization Unit', description: 'Verantwoordelijk voor waarde en bedrijfsvoering van het systeem' },
{ jiraId: 2366, name: 'System Owner', fieldName: 'systemOwner', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 103, referenceTypeName: 'User', description: 'Verantwoordelijk voor technische werking en beheer van het systeem.' },
{ jiraId: 2371, name: 'Functional Application Management', fieldName: 'functionalApplicationManagement', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 4613, name: 'Technical Application Management', fieldName: 'technicalApplicationManagement', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 400, referenceTypeName: 'group' },
{ jiraId: 377, name: 'Technical Application Management Primary', fieldName: 'technicalApplicationManagementPrimary', type: 'boolean', isMultiple: true, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 1330, name: 'Technical Application Management Secondary', fieldName: 'technicalApplicationManagementSecondary', type: 'boolean', isMultiple: true, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 1331, name: 'Medische Techniek', fieldName: 'medischeTechniek', type: 'boolean', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, description: 'Is er een link met medische techniek' },
{ jiraId: 572, name: 'Technische Architectuur (TA)', fieldName: 'technischeArchitectuurTA', type: 'email', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, description: 'Komt uit Enterprise Architect mee' },
{ jiraId: 4497, name: 'Measures', fieldName: 'measures', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 4749, name: 'GenerateConfluenceSpace', fieldName: 'generateConfluenceSpace', type: 'boolean', isMultiple: false, isEditable: false, isRequired: false, isSystem: false, description: 'Wordt gebruikt door script om space te genereren - niet verwijderen. Attribuut is hidden' },
{ jiraId: 4793, name: 'SourceStatus', fieldName: 'sourceStatus', type: 'reference', isMultiple: false, isEditable: false, isRequired: false, isSystem: false },
{ jiraId: 4799, name: 'ImportDate', fieldName: 'importDate', type: 'date', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 4906, name: 'ICT Governance Model', fieldName: 'ictGovernanceModel', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 431, referenceTypeName: 'IctGovernanceModel' },
{ jiraId: 4918, name: 'Application Management - Application Type', fieldName: 'applicationManagementApplicationType', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 436, referenceTypeName: 'ApplicationManagementApplicationType', description: 'De Type Classificatie bepaalt de aard en scope van het IT-object.' },
{ jiraId: 4939, name: 'Application Management - Hosting', fieldName: 'applicationManagementHosting', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 438, referenceTypeName: 'ApplicationManagementHosting', description: 'Het Hosting-veld geeft aan waar de infrastructuur draait. Dit bepaalt mede de technische verantwoordelijkheden en compliance-eisen.' },
{ jiraId: 4945, name: 'Application Management - TAM', fieldName: 'applicationManagementTAM', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 439, referenceTypeName: 'ApplicationManagementTam', description: 'Dit veld geeft aan wie het technisch applicatiebeheer uitvoert. Dit is de primaire factor voor het bepalen van het regiemodel.' },
{ jiraId: 4903, name: 'Application Management - Dynamics Factor', fieldName: 'applicationManagementDynamicsFactor', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 434, referenceTypeName: 'ApplicationManagementDynamicsFactor' },
{ jiraId: 4904, name: 'Application Management - Complexity Factor', fieldName: 'applicationManagementComplexityFactor', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 432, referenceTypeName: 'ApplicationManagementComplexityFactor' },
{ jiraId: 4905, name: 'Application Management - Number of Users', fieldName: 'applicationManagementNumberOfUsers', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 433, referenceTypeName: 'ApplicationManagementNumberOfUsers' },
{ jiraId: 4911, name: 'Application Management - Subteam', fieldName: 'applicationManagementSubteam', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 435, referenceTypeName: 'ApplicationManagementSubteam' },
{ jiraId: 4932, name: 'Application Management - Override FTE', fieldName: 'applicationManagementOverrideFTE', type: 'float', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }
],
},
'Flows': {
jiraTypeId: 59,
name: 'Flows',
typeName: 'Flows',
syncPriority: 1,
objectCount: 903,
attributes: [
{ jiraId: 1104, name: 'Reference', fieldName: 'reference', type: 'text', isMultiple: false, isEditable: false, isRequired: false, isSystem: false },
{ jiraId: 1105, name: 'Present In Import Enterprise Architect', fieldName: 'presentInImportEnterpriseArchitect', type: 'boolean', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 558, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 559, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' },
{ jiraId: 567, name: 'Source', fieldName: 'source', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 38, referenceTypeName: 'ApplicationComponent', description: 'Application Component - Source' },
{ jiraId: 4535, name: 'Search Reference Source', fieldName: 'searchReferenceSource', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 568, name: 'Target', fieldName: 'target', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 38, referenceTypeName: 'ApplicationComponent', description: 'Application Component - Target' },
{ jiraId: 4536, name: 'Search Reference Target', fieldName: 'searchReferenceTarget', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 564, name: 'Type', fieldName: 'type', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 565, name: 'Protocol', fieldName: 'protocol', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 566, name: 'Details', fieldName: 'details', type: 'select', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 570, name: 'Broker', fieldName: 'broker', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 571, name: 'Status', fieldName: 'status', type: 'email', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 560, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 561, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 4801, name: 'ImportDate', fieldName: 'importDate', type: 'date', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }
],
},
'Server': {
jiraTypeId: 48,
name: 'Server',
typeName: 'Server',
syncPriority: 1,
objectCount: 909,
attributes: [
{ jiraId: 417, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 418, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' },
{ jiraId: 450, name: 'Description', fieldName: 'description', type: 'select', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 441, name: 'Status', fieldName: 'status', type: 'email', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 4500, name: 'VMLocation', fieldName: 'vMLocation', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 4501, name: 'Application Component', fieldName: 'applicationComponent', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 38, referenceTypeName: 'ApplicationComponent' },
{ jiraId: 4762, name: 'AzureSubscription', fieldName: 'azureSubscription', type: 'reference', isMultiple: true, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 418, referenceTypeName: 'AzureSubscription' },
{ jiraId: 455, name: 'VMOSType', fieldName: 'vMOSType', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 2406, name: 'Reboot group', fieldName: 'rebootGroup', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 251, referenceTypeName: 'Rebootgroups' },
{ jiraId: 444, name: 'MemoryMB', fieldName: 'memoryMB', type: 'integer', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 2383, name: 'CPUCores', fieldName: 'cPUCores', type: 'integer', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 2384, name: 'NICCount', fieldName: 'nICCount', type: 'integer', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 2385, name: 'DataDisks', fieldName: 'dataDisks', type: 'integer', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 4720, name: 'PrivateIPAddress', fieldName: 'privateIPAddress', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 4764, name: 'Cluster', fieldName: 'cluster', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 4765, name: 'VMSize', fieldName: 'vMSize', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 419, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 420, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 4592, name: 'DBA', fieldName: 'dBA', type: 'boolean', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 4792, name: 'SourceStatus', fieldName: 'sourceStatus', type: 'reference', isMultiple: false, isEditable: false, isRequired: false, isSystem: false },
{ jiraId: 4802, name: 'ImportDate', fieldName: 'importDate', type: 'date', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 4873, name: 'State', fieldName: 'state', type: 'email', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }
],
},
'AzureSubscription': {
jiraTypeId: 418,
name: 'AzureSubscription',
typeName: 'AzureSubscription',
syncPriority: 2,
objectCount: 151,
attributes: [
{ jiraId: 4755, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 4756, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the subscription' },
{ jiraId: 4761, name: 'Status', fieldName: 'status', type: 'email', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 4760, name: 'Server', fieldName: 'server', type: 'reference', isMultiple: true, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 48, referenceTypeName: 'Server' },
{ jiraId: 4759, name: 'ApplicationComponent', fieldName: 'applicationComponent', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 38, referenceTypeName: 'ApplicationComponent' },
{ jiraId: 4757, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 4758, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 4803, name: 'ImportDate', fieldName: 'importDate', type: 'date', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }
],
},
'PackageBuild': {
jiraTypeId: 424,
name: 'PackageBuild',
typeName: 'PackageBuild',
syncPriority: 2,
objectCount: 496,
attributes: [
{ jiraId: 4816, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 4817, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'naamgeving: software-versie' },
{ jiraId: 4820, name: 'Software', fieldName: 'software', type: 'reference', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, referenceTypeId: 421, referenceTypeName: 'Software' },
{ jiraId: 4821, name: 'Version', fieldName: 'version', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 4835, name: 'Status', fieldName: 'status', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 4818, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 4819, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 4839, name: 'Description', fieldName: 'description', type: 'select', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 4824, name: 'BuildNumber', fieldName: 'buildNumber', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 4825, name: 'Architecture', fieldName: 'architecture', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 4826, name: 'Environment', fieldName: 'environment', type: 'reference', isMultiple: true, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 4827, name: 'Language', fieldName: 'language', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 4828, name: 'AppDelivery', fieldName: 'appDelivery', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 4829, name: 'ADlocalMemberships', fieldName: 'aDlocalMemberships', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 4830, name: 'ADmemberships', fieldName: 'aDmemberships', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 4831, name: 'EntraIDmemberships', fieldName: 'entraIDmemberships', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 4832, name: 'Platform', fieldName: 'platform', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 4833, name: 'InGoldenImage', fieldName: 'inGoldenImage', type: 'boolean', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 4837, name: 'ImportDate', fieldName: 'importDate', type: 'date', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }
],
},
'Package': {
jiraTypeId: 422,
name: 'Package',
typeName: 'Package',
syncPriority: 2,
objectCount: 299,
attributes: [
{ jiraId: 4806, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 4807, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' },
{ jiraId: 4808, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 4809, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 4836, name: 'Description', fieldName: 'description', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 4810, name: 'ApplicationComponent', fieldName: 'applicationComponent', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 38, referenceTypeName: 'ApplicationComponent' },
{ jiraId: 4811, name: 'Status', fieldName: 'status', type: 'email', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 4812, name: 'SupplierProduct', fieldName: 'supplierProduct', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 37, referenceTypeName: 'Supplier' },
{ jiraId: 4813, name: 'SupplierTechnical', fieldName: 'supplierTechnical', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 37, referenceTypeName: 'Supplier' },
{ jiraId: 4814, name: 'BusinessImportance', fieldName: 'businessImportance', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 4815, name: 'Authentication', fieldName: 'authentication', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 4834, name: 'MaintenanceContract', fieldName: 'maintenanceContract', type: 'boolean', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 4838, name: 'ImportDate', fieldName: 'importDate', type: 'date', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }
],
},
'Certificate': {
jiraTypeId: 406,
name: 'Certificate',
typeName: 'Certificate',
syncPriority: 2,
objectCount: 508,
attributes: [
{ jiraId: 4675, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 4676, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'Naam van het certificaat' },
{ jiraId: 4686, name: 'Status', fieldName: 'status', type: 'email', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'Status is Active, Closed, Unknown (date 1970-01-01) or Support Requested (ticket aangemaakt vanwege Expiry Date)' },
{ jiraId: 4695, name: 'Type', fieldName: 'type', type: 'reference', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, referenceTypeId: 407, referenceTypeName: 'CertificateType', description: 'Type certificaat' },
{ jiraId: 4696, name: 'Classification Type', fieldName: 'classificationType', type: 'reference', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, referenceTypeId: 408, referenceTypeName: 'CertificateClassificationType', description: 'Classificatie type' },
{ jiraId: 4698, name: 'Requester', fieldName: 'requester', type: 'boolean', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'De aanvrager (medewerker) van het betreffende certificaat' },
{ jiraId: 4682, name: 'Issuing Authority', fieldName: 'issuingAuthority', type: 'reference', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, referenceTypeId: 37, referenceTypeName: 'Supplier', description: 'Het bedrijf of de organisatie/instantie die verantwoordelijk is voor de uitgifte van het certificaat' },
{ jiraId: 4702, name: 'Issuing Supplier', fieldName: 'issuingSupplier', type: 'reference', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, referenceTypeId: 37, referenceTypeName: 'Supplier', description: 'Het bedrijf of de organisatie/instantie waar het certificaat is besteld.' },
{ jiraId: 4697, name: 'Autorenew', fieldName: 'autorenew', type: 'boolean', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, description: 'Indien een expiry date beschikbaar, dan op False' },
{ jiraId: 4721, name: 'Expiry Date', fieldName: 'expiryDate', type: 'date', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, description: ' Als er geen Expiry Date bekend is, wordt de waarde ingesteld op 01-01-1970' },
{ jiraId: 4753, name: 'ReminderInDays', fieldName: 'reminderInDays', type: 'reference', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'Aantal dagen vanaf welk moment er een reminder mechanisme wordt gestart' },
{ jiraId: 4797, name: 'ReminderMailbox', fieldName: 'reminderMailbox', type: 'boolean', isMultiple: true, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 4683, name: 'Certificate Owner', fieldName: 'certificateOwner', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 243, referenceTypeName: 'Organization Unit', description: 'De (eind)verantwoordelijke (medewerker) van het certificaat binnen Zuyderland' },
{ jiraId: 4699, name: 'IT Operations Team', fieldName: 'itOperationsTeam', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 400, referenceTypeName: 'group', description: 'Het team dat verantwoordelijk is voor de installatie / configuratie certificaat' },
{ jiraId: 4700, name: 'Application Management', fieldName: 'applicationManagement', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 400, referenceTypeName: 'group', description: 'Het team dat verantwoordelijk is voor het applicatiebeheer van de gekoppelde Application Component / Dienst' },
{ jiraId: 4701, name: 'Application Component', fieldName: 'applicationComponent', type: 'reference', isMultiple: true, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 38, referenceTypeName: 'ApplicationComponent', description: 'Verwijzing naar Application Component' },
{ jiraId: 4680, name: 'Domain', fieldName: 'domain', type: 'reference', isMultiple: true, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 252, referenceTypeName: 'Domain', description: 'Verwijzing naar Domain' },
{ jiraId: 4681, name: 'Server', fieldName: 'server', type: 'reference', isMultiple: true, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 48, referenceTypeName: 'Server', description: 'Verwijzing naar Server' },
{ jiraId: 4679, name: 'Description', fieldName: 'description', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, description: 'Algemene omschrijving' },
{ jiraId: 4719, name: 'Extra Installatie', fieldName: 'extraInstallatie', type: 'reference', isMultiple: true, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 38, referenceTypeName: 'ApplicationComponent' },
{ jiraId: 4677, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 4678, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }
],
},
'PrivilegedUser': {
jiraTypeId: 401,
name: 'Privileged User',
typeName: 'PrivilegedUser',
syncPriority: 2,
objectCount: 728,
attributes: [
{ jiraId: 4616, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 4617, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' },
{ jiraId: 4620, name: 'UPN', fieldName: 'uPN', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, description: 'User Principal Name' },
{ jiraId: 4621, name: 'Description', fieldName: 'description', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, description: 'Description' },
{ jiraId: 4622, name: 'Topdesk ticket', fieldName: 'topdeskTicket', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 4623, name: 'Application Component', fieldName: 'applicationComponent', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 38, referenceTypeName: 'ApplicationComponent' },
{ jiraId: 4624, name: 'E-Mail', fieldName: 'eMail', type: 'textarea', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 4625, name: 'Mobile', fieldName: 'mobile', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 4626, name: 'MethodsRegistered', fieldName: 'methodsRegistered', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 4627, name: 'Creation Date', fieldName: 'creationDate', type: 'url', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 4628, name: 'Expiry Date', fieldName: 'expiryDate', type: 'url', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 4629, name: 'Last Updated', fieldName: 'lastUpdated', type: 'url', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 4630, name: 'Password Last Set', fieldName: 'passwordLastSet', type: 'url', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 4631, name: 'Password never expires', fieldName: 'passwordNeverExpires', type: 'boolean', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 4632, name: 'MFA Registered', fieldName: 'mfaRegistered', type: 'boolean', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 4633, name: 'Self-Service Password Reset Used', fieldName: 'selfServicePasswordResetUsed', type: 'boolean', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 4653, name: 'Self-Service Password Reset Enabled', fieldName: 'selfServicePasswordResetEnabled', type: 'boolean', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 4634, name: 'Last Logon', fieldName: 'lastLogon', type: 'url', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 4618, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 4619, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 4652, name: 'SID', fieldName: 'sID', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 4800, name: 'ImportDate', fieldName: 'importDate', type: 'date', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }
],
},
'Domain': {
jiraTypeId: 252,
name: 'Domain',
typeName: 'Domain',
syncPriority: 2,
objectCount: 796,
attributes: [
{ jiraId: 2398, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 2399, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' },
{ jiraId: 2465, name: 'Status', fieldName: 'status', type: 'email', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 2466, name: 'Description', fieldName: 'description', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 4672, name: 'Registration date', fieldName: 'registrationDate', type: 'date', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 4668, name: 'Domain Type', fieldName: 'domainType', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 2471, name: 'Domain Function', fieldName: 'domainFunction', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 4669, name: 'DNS Management', fieldName: 'dnsManagement', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 37, referenceTypeName: 'Supplier' },
{ jiraId: 4673, name: 'Application Component', fieldName: 'applicationComponent', type: 'reference', isMultiple: true, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 38, referenceTypeName: 'ApplicationComponent' },
{ jiraId: 2467, name: 'Application Component Monitoring', fieldName: 'applicationComponentMonitoring', type: 'reference', isMultiple: true, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 38, referenceTypeName: 'ApplicationComponent' },
{ jiraId: 4543, name: 'Website availability', fieldName: 'websiteAvailability', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, description: 'Intern/extern/local' },
{ jiraId: 4685, name: 'Redirect URL', fieldName: 'redirectURL', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 4674, name: 'Mail enabled', fieldName: 'mailEnabled', type: 'boolean', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 2472, name: 'Business Owner', fieldName: 'businessOwner', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 243, referenceTypeName: 'Organization Unit' },
{ jiraId: 2474, name: 'Confluence', fieldName: 'confluence', type: 'float', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 2400, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 2401, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }
],
},
'Supplier': {
jiraTypeId: 37,
name: 'Supplier',
typeName: 'Supplier',
syncPriority: 2,
objectCount: 471,
attributes: [
{ jiraId: 337, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 338, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' },
{ jiraId: 339, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 340, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 1143, name: 'Address', fieldName: 'address', type: 'select', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 4635, name: 'Street', fieldName: 'street', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 4636, name: 'House number', fieldName: 'houseNumber', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 4637, name: 'City', fieldName: 'city', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 4638, name: 'Postal code', fieldName: 'postalCode', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 4639, name: 'Country code', fieldName: 'countryCode', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 1144, name: 'Company Telephone', fieldName: 'companyTelephone', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 1145, name: 'Company Website', fieldName: 'companyWebsite', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 1146, name: 'Company email', fieldName: 'companyEmail', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 1147, name: 'Servicedesk Telephone', fieldName: 'servicedeskTelephone', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 1148, name: 'Servicedesk email', fieldName: 'servicedeskEmail', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 1149, name: 'Consultant 1', fieldName: 'consultant1', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 2412, name: 'Consultant 1 e-mail', fieldName: 'consultant1EMail', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 1150, name: 'Consultant 2', fieldName: 'consultant2', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 2413, name: 'Consultant 2 e-mail', fieldName: 'consultant2EMail', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 1151, name: 'Consultant 3', fieldName: 'consultant3', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 2414, name: 'Consultant 3 e-mail', fieldName: 'consultant3EMail', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }
],
},
'Software': {
jiraTypeId: 421,
name: 'Software',
typeName: 'Software',
syncPriority: 2,
objectCount: 307,
attributes: [
{ jiraId: 4806, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 4807, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' },
{ jiraId: 4808, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 4809, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 4836, name: 'Description', fieldName: 'description', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 4810, name: 'ApplicationComponent', fieldName: 'applicationComponent', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 38, referenceTypeName: 'ApplicationComponent' },
{ jiraId: 4811, name: 'Status', fieldName: 'status', type: 'email', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 4812, name: 'SupplierProduct', fieldName: 'supplierProduct', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 37, referenceTypeName: 'Supplier' },
{ jiraId: 4813, name: 'SupplierTechnical', fieldName: 'supplierTechnical', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 37, referenceTypeName: 'Supplier' }
],
},
'SoftwarePatch': {
jiraTypeId: 423,
name: 'SoftwarePatch',
typeName: 'SoftwarePatch',
syncPriority: 2,
objectCount: 555,
attributes: [
{ jiraId: 4816, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 4817, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'naamgeving: software-versie' },
{ jiraId: 4820, name: 'Software', fieldName: 'software', type: 'reference', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, referenceTypeId: 421, referenceTypeName: 'Software' },
{ jiraId: 4821, name: 'Version', fieldName: 'version', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 4835, name: 'Status', fieldName: 'status', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 4818, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 4819, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 4839, name: 'Description', fieldName: 'description', type: 'select', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }
],
},
'Manual': {
jiraTypeId: 430,
name: 'Manual',
typeName: 'Manual',
syncPriority: 5,
objectCount: 23,
attributes: [
{ jiraId: 4865, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 4866, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' },
{ jiraId: 4869, name: 'Link', fieldName: 'link', type: 'float', isMultiple: true, isEditable: true, isRequired: false, isSystem: false, description: 'Confluence manual page' },
{ jiraId: 4870, name: 'Type', fieldName: 'type', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 4867, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 4868, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 4871, name: 'Target', fieldName: 'target', type: 'reference', isMultiple: true, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 430, referenceTypeName: 'Manual', description: 'If applicable' }
],
},
'Measures': {
jiraTypeId: 391,
name: 'Measures',
typeName: 'Measures',
syncPriority: 5,
objectCount: 11,
attributes: [
{ jiraId: 4512, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 4513, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' },
{ jiraId: 4516, name: 'Description', fieldName: 'description', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 4522, name: 'Obliged', fieldName: 'obliged', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 4521, name: 'Availability', fieldName: 'availability', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 4523, name: 'Optional', fieldName: 'optional', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 4514, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 4515, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }
],
},
'Rebootgroups': {
jiraTypeId: 251,
name: 'Rebootgroups',
typeName: 'Rebootgroups',
syncPriority: 5,
objectCount: 14,
attributes: [
{ jiraId: 2391, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 2392, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' },
{ jiraId: 2397, name: 'Reboot day', fieldName: 'rebootDay', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 2395, name: 'Reboot time', fieldName: 'rebootTime', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 2396, name: 'Install information', fieldName: 'installInformation', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 2393, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 2394, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }
],
},
'ApplicationManagementTeam': {
jiraTypeId: 440,
name: 'Application Management - Team',
typeName: 'ApplicationManagementTeam',
syncPriority: 5,
objectCount: 11,
attributes: [
{ jiraId: 4946, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 4947, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' },
{ jiraId: 4948, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 4949, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 4950, name: 'Type', fieldName: 'type', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }
],
},
'ApplicationManagementSubteam': {
jiraTypeId: 435,
name: 'Application Management - Subteam',
typeName: 'ApplicationManagementSubteam',
syncPriority: 5,
objectCount: 19,
attributes: [
{ jiraId: 4907, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 4951, name: 'Application Management - Team', fieldName: 'applicationManagementTeam', type: 'reference', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, referenceTypeId: 440, referenceTypeName: 'ApplicationManagementTeam' },
{ jiraId: 4908, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' },
{ jiraId: 4909, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 4910, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 4912, name: 'Description', fieldName: 'description', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }
],
},
'Assets': {
jiraTypeId: 42,
name: 'Assets',
typeName: 'Assets',
syncPriority: 8,
objectCount: 0,
attributes: [
{ jiraId: 384, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 385, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' },
{ jiraId: 386, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 387, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }
],
},
'CI': {
jiraTypeId: 392,
name: 'CI',
typeName: 'CI',
syncPriority: 8,
objectCount: 0,
attributes: [
{ jiraId: 4552, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 4553, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' },
{ jiraId: 4554, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 4555, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }
],
},
'Metadata': {
jiraTypeId: 57,
name: 'Metadata',
typeName: 'Metadata',
syncPriority: 8,
objectCount: 0,
attributes: [
{ jiraId: 499, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 500, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' },
{ jiraId: 4705, name: 'Description', fieldName: 'description', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 501, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 502, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }
],
},
'ApplicationManagementHosting': {
jiraTypeId: 438,
name: 'Application Management - Hosting',
typeName: 'ApplicationManagementHosting',
syncPriority: 8,
objectCount: 4,
attributes: [
{ jiraId: 4933, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 4934, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' },
{ jiraId: 4935, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 4936, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 4938, name: 'Description', fieldName: 'description', type: 'select', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }
],
},
'ApplicationManagementTam': {
jiraTypeId: 439,
name: 'Application Management - TAM',
typeName: 'ApplicationManagementTam',
syncPriority: 8,
objectCount: 4,
attributes: [
{ jiraId: 4940, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 4941, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' },
{ jiraId: 4942, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 4943, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 4944, name: 'Description', fieldName: 'description', type: 'select', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }
],
},
'ApplicationManagementNumberOfUsers': {
jiraTypeId: 433,
name: 'Application Management - Number of Users',
typeName: 'ApplicationManagementNumberOfUsers',
syncPriority: 8,
objectCount: 7,
attributes: [
{ jiraId: 4889, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 4890, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' },
{ jiraId: 4891, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 4892, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 4893, name: 'Examples', fieldName: 'examples', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 4894, name: 'Factor', fieldName: 'factor', type: 'float', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 4895, name: 'Order', fieldName: 'order', type: 'integer', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }
],
},
'CertificateType': {
jiraTypeId: 407,
name: 'Certificate Type',
typeName: 'CertificateType',
syncPriority: 10,
objectCount: 3,
attributes: [
{ jiraId: 4687, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 4688, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' },
{ jiraId: 4689, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 4690, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }
],
},
'ApplicationFunctionCategory': {
jiraTypeId: 437,
name: 'ApplicationFunctionCategory',
typeName: 'ApplicationFunctionCategory',
syncPriority: 10,
objectCount: 13,
attributes: [
{ jiraId: 4921, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 4922, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' },
{ jiraId: 4923, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 4924, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 4925, name: 'Description', fieldName: 'description', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }
],
},
'ApplicationFunction': {
jiraTypeId: 403,
name: 'ApplicationFunction',
typeName: 'ApplicationFunction',
syncPriority: 10,
objectCount: 93,
attributes: [
{ jiraId: 4650, name: 'AppFuncGUID', fieldName: 'appFuncGUID', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 4646, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 4926, name: 'Application Function Category', fieldName: 'applicationFunctionCategory', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 437, referenceTypeName: 'ApplicationFunctionCategory' },
{ jiraId: 4647, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' },
{ jiraId: 4651, name: 'Description', fieldName: 'description', type: 'select', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 4920, name: 'Keywords', fieldName: 'keywords', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 4648, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 4649, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 4919, name: 'Application Management - Application Cluster', fieldName: 'applicationManagementApplicationCluster', type: 'reference', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, referenceTypeId: 435, referenceTypeName: 'ApplicationManagementSubteam' }
],
},
'CertificateClassificationType': {
jiraTypeId: 408,
name: 'Certificate ClassificationType',
typeName: 'CertificateClassificationType',
syncPriority: 10,
objectCount: 8,
attributes: [
{ jiraId: 4691, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 4692, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' },
{ jiraId: 4703, name: 'Description', fieldName: 'description', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false, description: 'https://jira.zuyderland.nl/browse/CMDB-430' },
{ jiraId: 4693, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 4694, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }
],
},
'BusinessImpactAnalyse': {
jiraTypeId: 41,
name: 'Business Impact Analyse',
typeName: 'BusinessImpactAnalyse',
syncPriority: 10,
objectCount: 6,
attributes: [
{ jiraId: 369, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 370, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' },
{ jiraId: 1107, name: 'Service Window', fieldName: 'serviceWindow', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 1108, name: 'Availabilty', fieldName: 'availabilty', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 2411, name: 'BIA Check', fieldName: 'biaCheck', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 1109, name: 'Recovery Time Objective (RTO)', fieldName: 'recoveryTimeObjectiveRTO', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 1110, name: 'Recovery Point Objective (RPO)', fieldName: 'recoveryPointObjectiveRPO', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 1111, name: 'Environments', fieldName: 'environments', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 4930, name: 'Description', fieldName: 'description', type: 'select', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 4931, name: 'Indicators', fieldName: 'indicators', type: 'select', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 371, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 372, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }
],
},
'BusinessImportance': {
jiraTypeId: 44,
name: 'Business Importance',
typeName: 'BusinessImportance',
syncPriority: 10,
objectCount: 8,
attributes: [
{ jiraId: 395, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 396, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' },
{ jiraId: 4517, name: 'Description', fieldName: 'description', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 397, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 398, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }
],
},
'HostingType': {
jiraTypeId: 39,
name: 'Hosting Type',
typeName: 'HostingType',
syncPriority: 10,
objectCount: 6,
attributes: [
{ jiraId: 345, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 346, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' },
{ jiraId: 4520, name: 'Description', fieldName: 'description', type: 'select', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 347, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 348, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }
],
},
'Organisation': {
jiraTypeId: 390,
name: 'Organisation',
typeName: 'Organisation',
syncPriority: 10,
objectCount: 6,
attributes: [
{ jiraId: 4507, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 4508, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' },
{ jiraId: 4511, name: 'Description', fieldName: 'description', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 4509, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 4510, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true }
],
},
'ApplicationManagementApplicationType': {
jiraTypeId: 436,
name: 'Application Management - Application Type',
typeName: 'ApplicationManagementApplicationType',
syncPriority: 10,
objectCount: 4,
attributes: [
{ jiraId: 4913, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 4914, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' },
{ jiraId: 4915, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 4916, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 4917, name: 'Description', fieldName: 'description', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }
],
},
'ApplicationManagementDynamicsFactor': {
jiraTypeId: 434,
name: 'Application Management - Dynamics Factor',
typeName: 'ApplicationManagementDynamicsFactor',
syncPriority: 10,
objectCount: 4,
attributes: [
{ jiraId: 4896, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 4897, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' },
{ jiraId: 4898, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 4899, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 4900, name: 'Summary', fieldName: 'summary', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 4928, name: 'Description', fieldName: 'description', type: 'select', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 4901, name: 'Factor', fieldName: 'factor', type: 'float', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }
],
},
'ApplicationManagementComplexityFactor': {
jiraTypeId: 432,
name: 'Application Management - Complexity Factor',
typeName: 'ApplicationManagementComplexityFactor',
syncPriority: 10,
objectCount: 4,
attributes: [
{ jiraId: 4883, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 4884, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' },
{ jiraId: 4885, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 4886, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 4887, name: 'Summary', fieldName: 'summary', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 4929, name: 'Description', fieldName: 'description', type: 'select', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 4888, name: 'Factor', fieldName: 'factor', type: 'float', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }
],
},
'IctGovernanceModel': {
jiraTypeId: 431,
name: 'ICT Governance Model',
typeName: 'IctGovernanceModel',
syncPriority: 10,
objectCount: 6,
attributes: [
{ jiraId: 4874, name: 'Key', fieldName: 'key', type: 'text', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 4875, name: 'Name', fieldName: 'name', type: 'text', isMultiple: false, isEditable: true, isRequired: true, isSystem: false, description: 'The name of the object' },
{ jiraId: 4876, name: 'Created', fieldName: 'created', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 4877, name: 'Updated', fieldName: 'updated', type: 'url', isMultiple: false, isEditable: false, isRequired: true, isSystem: true },
{ jiraId: 4878, name: 'Summary', fieldName: 'summary', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 4879, name: 'Description', fieldName: 'description', type: 'text', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 4880, name: 'Remarks', fieldName: 'remarks', type: 'select', isMultiple: false, isEditable: true, isRequired: false, isSystem: false },
{ jiraId: 4881, name: 'Application', fieldName: 'application', type: 'select', isMultiple: false, isEditable: true, isRequired: false, isSystem: false }
],
}
};
// =============================================================================
// Lookup Maps
// =============================================================================
/** Map from Jira Type ID to TypeScript type name */
export const TYPE_ID_TO_NAME: Record<number, string> = {
38: 'ApplicationComponent',
59: 'Flows',
48: 'Server',
418: 'AzureSubscription',
424: 'PackageBuild',
422: 'Package',
406: 'Certificate',
401: 'PrivilegedUser',
252: 'Domain',
37: 'Supplier',
421: 'Software',
423: 'SoftwarePatch',
430: 'Manual',
391: 'Measures',
251: 'Rebootgroups',
440: 'ApplicationManagementTeam',
435: 'ApplicationManagementSubteam',
42: 'Assets',
392: 'CI',
57: 'Metadata',
438: 'ApplicationManagementHosting',
439: 'ApplicationManagementTam',
433: 'ApplicationManagementNumberOfUsers',
407: 'CertificateType',
437: 'ApplicationFunctionCategory',
403: 'ApplicationFunction',
408: 'CertificateClassificationType',
41: 'BusinessImpactAnalyse',
44: 'BusinessImportance',
39: 'HostingType',
390: 'Organisation',
436: 'ApplicationManagementApplicationType',
434: 'ApplicationManagementDynamicsFactor',
432: 'ApplicationManagementComplexityFactor',
431: 'IctGovernanceModel',
};
/** Map from TypeScript type name to Jira Type ID */
export const TYPE_NAME_TO_ID: Record<string, number> = {
'ApplicationComponent': 38,
'Flows': 59,
'Server': 48,
'AzureSubscription': 418,
'PackageBuild': 424,
'Package': 422,
'Certificate': 406,
'PrivilegedUser': 401,
'Domain': 252,
'Supplier': 37,
'Software': 421,
'SoftwarePatch': 423,
'Manual': 430,
'Measures': 391,
'Rebootgroups': 251,
'ApplicationManagementTeam': 440,
'ApplicationManagementSubteam': 435,
'Assets': 42,
'CI': 392,
'Metadata': 57,
'ApplicationManagementHosting': 438,
'ApplicationManagementTam': 439,
'ApplicationManagementNumberOfUsers': 433,
'CertificateType': 407,
'ApplicationFunctionCategory': 437,
'ApplicationFunction': 403,
'CertificateClassificationType': 408,
'BusinessImpactAnalyse': 41,
'BusinessImportance': 44,
'HostingType': 39,
'Organisation': 390,
'ApplicationManagementApplicationType': 436,
'ApplicationManagementDynamicsFactor': 434,
'ApplicationManagementComplexityFactor': 432,
'IctGovernanceModel': 431,
};
/** Map from Jira object type name to TypeScript type name */
export const JIRA_NAME_TO_TYPE: Record<string, string> = {
'Application Component': 'ApplicationComponent',
'Flows': 'Flows',
'Server': 'Server',
'AzureSubscription': 'AzureSubscription',
'PackageBuild': 'PackageBuild',
'Package': 'Package',
'Certificate': 'Certificate',
'Privileged User': 'PrivilegedUser',
'Domain': 'Domain',
'Supplier': 'Supplier',
'Software': 'Software',
'SoftwarePatch': 'SoftwarePatch',
'Manual': 'Manual',
'Measures': 'Measures',
'Rebootgroups': 'Rebootgroups',
'Application Management - Team': 'ApplicationManagementTeam',
'Application Management - Subteam': 'ApplicationManagementSubteam',
'Assets': 'Assets',
'CI': 'CI',
'Metadata': 'Metadata',
'Application Management - Hosting': 'ApplicationManagementHosting',
'Application Management - TAM': 'ApplicationManagementTam',
'Application Management - Number of Users': 'ApplicationManagementNumberOfUsers',
'Certificate Type': 'CertificateType',
'ApplicationFunctionCategory': 'ApplicationFunctionCategory',
'ApplicationFunction': 'ApplicationFunction',
'Certificate ClassificationType': 'CertificateClassificationType',
'Business Impact Analyse': 'BusinessImpactAnalyse',
'Business Importance': 'BusinessImportance',
'Hosting Type': 'HostingType',
'Organisation': 'Organisation',
'Application Management - Application Type': 'ApplicationManagementApplicationType',
'Application Management - Dynamics Factor': 'ApplicationManagementDynamicsFactor',
'Application Management - Complexity Factor': 'ApplicationManagementComplexityFactor',
'ICT Governance Model': 'IctGovernanceModel',
};
// =============================================================================
// Helper Functions
// =============================================================================
/** Get attribute definition by type and field name */
export function getAttributeDefinition(typeName: string, fieldName: string): AttributeDefinition | undefined {
const objectType = OBJECT_TYPES[typeName];
if (!objectType) return undefined;
return objectType.attributes.find(a => a.fieldName === fieldName);
}
/** Get attribute definition by type and Jira attribute ID */
export function getAttributeById(typeName: string, jiraId: number): AttributeDefinition | undefined {
const objectType = OBJECT_TYPES[typeName];
if (!objectType) return undefined;
return objectType.attributes.find(a => a.jiraId === jiraId);
}
/** Get attribute definition by type and Jira attribute name */
export function getAttributeByName(typeName: string, attrName: string): AttributeDefinition | undefined {
const objectType = OBJECT_TYPES[typeName];
if (!objectType) return undefined;
return objectType.attributes.find(a => a.name === attrName);
}
/** Get attribute Jira ID by type and attribute name - throws if not found */
export function getAttributeId(typeName: string, attrName: string): number {
const attr = getAttributeByName(typeName, attrName);
if (!attr) {
throw new Error(`Attribute "${attrName}" not found on type "${typeName}"`);
}
return attr.jiraId;
}
/** Get all reference attributes for a type */
export function getReferenceAttributes(typeName: string): AttributeDefinition[] {
const objectType = OBJECT_TYPES[typeName];
if (!objectType) return [];
return objectType.attributes.filter(a => a.type === 'reference');
}
/** Get all object types sorted by sync priority */
export function getObjectTypesBySyncPriority(): ObjectTypeDefinition[] {
return Object.values(OBJECT_TYPES).sort((a, b) => a.syncPriority - b.syncPriority);
}

View File

@@ -0,0 +1,933 @@
// AUTO-GENERATED FILE - DO NOT EDIT MANUALLY
// Generated from Jira Assets Schema via REST API
// Generated at: 2026-01-09T02:12:50.973Z
//
// Re-generate with: npm run generate-schema
// =============================================================================
// Base Types
// =============================================================================
/** Reference to another CMDB object */
export interface ObjectReference {
objectId: string;
objectKey: string;
label: string;
// Optional enriched data from referenced object
factor?: number;
}
/** Base interface for all CMDB objects */
export interface BaseCMDBObject {
id: string;
objectKey: string;
label: string;
_objectType: string;
_jiraUpdatedAt: string;
_jiraCreatedAt: string;
}
// =============================================================================
// Object Type Interfaces
// =============================================================================
/** Application Component (Jira Type ID: 38, 596 objects) */
export interface ApplicationComponent extends BaseCMDBObject {
_objectType: 'ApplicationComponent';
// Scalar attributes
reference: string | null; // Niet aanpassen. GUID - Enterprise Architect
key: string | null;
searchReference: string | null; // Additionele zoekwoorden t.b.v. search
name: string | null; // Unieke naam object
created: string | null;
updated: string | null;
description: string | null; // * Application description
status: string | null; // Application Lifecycle Management
confluenceSpace: number | null;
zenyaID: number | null;
zenyaURL: string | null;
customDevelopment: boolean | null; // Is er sprake van eigen programmatuur?
piiData: boolean | null; // Maakt applicatie gebruik van Persoonlijk identificeerbare informatie?
medicalData: boolean | null; // Maakt de Application Component gebruik van medische data?
functionalApplicationManagement: string | null;
technicalApplicationManagementPrimary: boolean[];
technicalApplicationManagementSecondary: boolean[];
medischeTechniek: boolean | null; // Is er een link met medische techniek
technischeArchitectuurTA: string | null; // Komt uit Enterprise Architect mee
measures: string | null;
generateConfluenceSpace: boolean | null; // Wordt gebruikt door script om space te genereren - niet verwijderen. Attribuut is hidden
importDate: string | null;
applicationManagementOverrideFTE: number | null;
// Reference attributes
organisation: ObjectReference | null; // -> Organisation
applicationFunction: ObjectReference[]; // -> ApplicationFunction
businessImportance: ObjectReference | null; // -> BusinessImportance
businessImpactAnalyse: ObjectReference | null; // -> BusinessImpactAnalyse
applicationComponentHostingType: ObjectReference | null; // -> HostingType
platform: ObjectReference | null; // -> ApplicationComponent
referencedApplicationComponent: ObjectReference[]; // -> ApplicationComponent
authenticationMethod: ObjectReference | null; // -> ApplicationComponent
monitoring: ObjectReference | null; // -> ApplicationComponent
supplierProduct: ObjectReference | null; // -> Supplier
supplierTechnical: ObjectReference | null; // -> Supplier
supplierImplementation: ObjectReference | null; // -> Supplier
supplierConsultancy: ObjectReference | null; // -> Supplier
businessOwner: ObjectReference | null; // -> Organization Unit
systemOwner: ObjectReference | null; // -> User
technicalApplicationManagement: ObjectReference | null; // -> group
sourceStatus: ObjectReference | null;
ictGovernanceModel: ObjectReference | null; // -> IctGovernanceModel
applicationManagementApplicationType: ObjectReference | null; // -> ApplicationManagementApplicationType
applicationManagementHosting: ObjectReference | null; // -> ApplicationManagementHosting
applicationManagementTAM: ObjectReference | null; // -> ApplicationManagementTam
applicationManagementDynamicsFactor: ObjectReference | null; // -> ApplicationManagementDynamicsFactor
applicationManagementComplexityFactor: ObjectReference | null; // -> ApplicationManagementComplexityFactor
applicationManagementNumberOfUsers: ObjectReference | null; // -> ApplicationManagementNumberOfUsers
applicationManagementSubteam: ObjectReference | null; // -> ApplicationManagementSubteam
}
/** Flows (Jira Type ID: 59, 903 objects) */
export interface Flows extends BaseCMDBObject {
_objectType: 'Flows';
// Scalar attributes
reference: string | null;
presentInImportEnterpriseArchitect: boolean | null;
key: string | null;
name: string | null; // The name of the object
searchReferenceSource: string | null;
searchReferenceTarget: string | null;
type: string | null;
protocol: string | null;
details: string | null;
broker: string | null;
status: string | null;
created: string | null;
updated: string | null;
importDate: string | null;
// Reference attributes
source: ObjectReference | null; // -> ApplicationComponent
target: ObjectReference | null; // -> ApplicationComponent
}
/** Server (Jira Type ID: 48, 909 objects) */
export interface Server extends BaseCMDBObject {
_objectType: 'Server';
// Scalar attributes
key: string | null;
name: string | null; // The name of the object
description: string | null;
status: string | null;
vMLocation: string | null;
vMOSType: string | null;
memoryMB: number | null;
cPUCores: number | null;
nICCount: number | null;
dataDisks: number | null;
privateIPAddress: string | null;
cluster: string | null;
vMSize: string | null;
created: string | null;
updated: string | null;
dBA: boolean | null;
importDate: string | null;
state: string | null;
// Reference attributes
applicationComponent: ObjectReference | null; // -> ApplicationComponent
azureSubscription: ObjectReference[]; // -> AzureSubscription
rebootGroup: ObjectReference | null; // -> Rebootgroups
sourceStatus: ObjectReference | null;
}
/** AzureSubscription (Jira Type ID: 418, 151 objects) */
export interface AzureSubscription extends BaseCMDBObject {
_objectType: 'AzureSubscription';
// Scalar attributes
key: string | null;
name: string | null; // The name of the subscription
status: string | null;
created: string | null;
updated: string | null;
importDate: string | null;
// Reference attributes
server: ObjectReference[]; // -> Server
applicationComponent: ObjectReference | null; // -> ApplicationComponent
}
/** PackageBuild (Jira Type ID: 424, 496 objects) */
export interface PackageBuild extends BaseCMDBObject {
_objectType: 'PackageBuild';
// Scalar attributes
key: string | null;
name: string | null; // naamgeving: software-versie
version: string | null;
created: string | null;
updated: string | null;
description: string | null;
buildNumber: string | null;
architecture: string | null;
language: string | null;
appDelivery: string | null;
aDlocalMemberships: string | null;
aDmemberships: string | null;
entraIDmemberships: string | null;
platform: string | null;
inGoldenImage: boolean | null;
importDate: string | null;
// Reference attributes
software: ObjectReference | null; // -> Software
status: ObjectReference | null;
environment: ObjectReference[];
}
/** Package (Jira Type ID: 422, 299 objects) */
export interface Package extends BaseCMDBObject {
_objectType: 'Package';
// Scalar attributes
key: string | null;
name: string | null; // The name of the object
created: string | null;
updated: string | null;
description: string | null;
status: string | null;
businessImportance: string | null;
authentication: string | null;
maintenanceContract: boolean | null;
importDate: string | null;
// Reference attributes
applicationComponent: ObjectReference | null; // -> ApplicationComponent
supplierProduct: ObjectReference | null; // -> Supplier
supplierTechnical: ObjectReference | null; // -> Supplier
}
/** Certificate (Jira Type ID: 406, 508 objects) */
export interface Certificate extends BaseCMDBObject {
_objectType: 'Certificate';
// Scalar attributes
key: string | null;
name: string | null; // Naam van het certificaat
status: string | null; // Status is Active, Closed, Unknown (date 1970-01-01) or Support Requested (ticket aangemaakt vanwege Expiry Date)
requester: boolean | null; // De aanvrager (medewerker) van het betreffende certificaat
autorenew: boolean | null; // Indien een expiry date beschikbaar, dan op False
expiryDate: string | null; // Als er geen Expiry Date bekend is, wordt de waarde ingesteld op 01-01-1970
reminderMailbox: boolean[];
description: string | null; // Algemene omschrijving
created: string | null;
updated: string | null;
// Reference attributes
type: ObjectReference | null; // -> CertificateType
classificationType: ObjectReference | null; // -> CertificateClassificationType
issuingAuthority: ObjectReference | null; // -> Supplier
issuingSupplier: ObjectReference | null; // -> Supplier
reminderInDays: ObjectReference | null;
certificateOwner: ObjectReference | null; // -> Organization Unit
itOperationsTeam: ObjectReference | null; // -> group
applicationManagement: ObjectReference | null; // -> group
applicationComponent: ObjectReference[]; // -> ApplicationComponent
domain: ObjectReference[]; // -> Domain
server: ObjectReference[]; // -> Server
extraInstallatie: ObjectReference[]; // -> ApplicationComponent
}
/** Privileged User (Jira Type ID: 401, 728 objects) */
export interface PrivilegedUser extends BaseCMDBObject {
_objectType: 'PrivilegedUser';
// Scalar attributes
key: string | null;
name: string | null; // The name of the object
uPN: string | null; // User Principal Name
description: string | null; // Description
topdeskTicket: string | null;
eMail: string | null;
mobile: string | null;
methodsRegistered: string | null;
creationDate: string | null;
expiryDate: string | null;
lastUpdated: string | null;
passwordLastSet: string | null;
passwordNeverExpires: boolean | null;
mfaRegistered: boolean | null;
selfServicePasswordResetUsed: boolean | null;
selfServicePasswordResetEnabled: boolean | null;
lastLogon: string | null;
created: string | null;
updated: string | null;
sID: string | null;
importDate: string | null;
// Reference attributes
applicationComponent: ObjectReference | null; // -> ApplicationComponent
}
/** Domain (Jira Type ID: 252, 796 objects) */
export interface Domain extends BaseCMDBObject {
_objectType: 'Domain';
// Scalar attributes
key: string | null;
name: string | null; // The name of the object
status: string | null;
description: string | null;
registrationDate: string | null;
redirectURL: string | null;
mailEnabled: boolean | null;
confluence: number | null;
created: string | null;
updated: string | null;
// Reference attributes
domainType: ObjectReference | null;
domainFunction: ObjectReference | null;
dnsManagement: ObjectReference | null; // -> Supplier
applicationComponent: ObjectReference[]; // -> ApplicationComponent
applicationComponentMonitoring: ObjectReference[]; // -> ApplicationComponent
websiteAvailability: ObjectReference | null;
businessOwner: ObjectReference | null; // -> Organization Unit
}
/** Supplier (Jira Type ID: 37, 471 objects) */
export interface Supplier extends BaseCMDBObject {
_objectType: 'Supplier';
// Scalar attributes
key: string | null;
name: string | null; // The name of the object
created: string | null;
updated: string | null;
address: string | null;
street: string | null;
houseNumber: string | null;
city: string | null;
postalCode: string | null;
countryCode: string | null;
companyTelephone: string | null;
companyWebsite: string | null;
companyEmail: string | null;
servicedeskTelephone: string | null;
servicedeskEmail: string | null;
consultant1: string | null;
consultant1EMail: string | null;
consultant2: string | null;
consultant2EMail: string | null;
consultant3: string | null;
consultant3EMail: string | null;
}
/** Software (Jira Type ID: 421, 307 objects) */
export interface Software extends BaseCMDBObject {
_objectType: 'Software';
// Scalar attributes
key: string | null;
name: string | null; // The name of the object
created: string | null;
updated: string | null;
description: string | null;
status: string | null;
// Reference attributes
applicationComponent: ObjectReference | null; // -> ApplicationComponent
supplierProduct: ObjectReference | null; // -> Supplier
supplierTechnical: ObjectReference | null; // -> Supplier
}
/** SoftwarePatch (Jira Type ID: 423, 555 objects) */
export interface SoftwarePatch extends BaseCMDBObject {
_objectType: 'SoftwarePatch';
// Scalar attributes
key: string | null;
name: string | null; // naamgeving: software-versie
version: string | null;
created: string | null;
updated: string | null;
description: string | null;
// Reference attributes
software: ObjectReference | null; // -> Software
status: ObjectReference | null;
}
/** Manual (Jira Type ID: 430, 23 objects) */
export interface Manual extends BaseCMDBObject {
_objectType: 'Manual';
// Scalar attributes
key: string | null;
name: string | null; // The name of the object
link: number[]; // Confluence manual page
created: string | null;
updated: string | null;
// Reference attributes
type: ObjectReference | null;
target: ObjectReference[]; // -> Manual
}
/** Measures (Jira Type ID: 391, 11 objects) */
export interface Measures extends BaseCMDBObject {
_objectType: 'Measures';
// Scalar attributes
key: string | null;
name: string | null; // The name of the object
description: string | null;
obliged: string | null;
created: string | null;
updated: string | null;
// Reference attributes
availability: ObjectReference | null;
optional: ObjectReference | null;
}
/** Rebootgroups (Jira Type ID: 251, 14 objects) */
export interface Rebootgroups extends BaseCMDBObject {
_objectType: 'Rebootgroups';
// Scalar attributes
key: string | null;
name: string | null; // The name of the object
rebootDay: string | null;
rebootTime: string | null;
installInformation: string | null;
created: string | null;
updated: string | null;
}
/** Application Management - Team (Jira Type ID: 440, 11 objects) */
export interface ApplicationManagementTeam extends BaseCMDBObject {
_objectType: 'ApplicationManagementTeam';
// Scalar attributes
key: string | null;
name: string | null; // The name of the object
created: string | null;
updated: string | null;
// Reference attributes
type: ObjectReference | null;
}
/** Application Management - Subteam (Jira Type ID: 435, 19 objects) */
export interface ApplicationManagementSubteam extends BaseCMDBObject {
_objectType: 'ApplicationManagementSubteam';
// Scalar attributes
key: string | null;
name: string | null; // The name of the object
created: string | null;
updated: string | null;
description: string | null;
// Reference attributes
applicationManagementTeam: ObjectReference | null; // -> ApplicationManagementTeam
}
/** Assets (Jira Type ID: 42, 0 objects) */
export interface Assets extends BaseCMDBObject {
_objectType: 'Assets';
// Scalar attributes
key: string | null;
name: string | null; // The name of the object
created: string | null;
updated: string | null;
}
/** CI (Jira Type ID: 392, 0 objects) */
export interface CI extends BaseCMDBObject {
_objectType: 'CI';
// Scalar attributes
key: string | null;
name: string | null; // The name of the object
created: string | null;
updated: string | null;
}
/** Metadata (Jira Type ID: 57, 0 objects) */
export interface Metadata extends BaseCMDBObject {
_objectType: 'Metadata';
// Scalar attributes
key: string | null;
name: string | null; // The name of the object
description: string | null;
created: string | null;
updated: string | null;
}
/** Application Management - Hosting (Jira Type ID: 438, 4 objects) */
export interface ApplicationManagementHosting extends BaseCMDBObject {
_objectType: 'ApplicationManagementHosting';
// Scalar attributes
key: string | null;
name: string | null; // The name of the object
created: string | null;
updated: string | null;
description: string | null;
}
/** Application Management - TAM (Jira Type ID: 439, 4 objects) */
export interface ApplicationManagementTam extends BaseCMDBObject {
_objectType: 'ApplicationManagementTam';
// Scalar attributes
key: string | null;
name: string | null; // The name of the object
created: string | null;
updated: string | null;
description: string | null;
}
/** Application Management - Number of Users (Jira Type ID: 433, 7 objects) */
export interface ApplicationManagementNumberOfUsers extends BaseCMDBObject {
_objectType: 'ApplicationManagementNumberOfUsers';
// Scalar attributes
key: string | null;
name: string | null; // The name of the object
created: string | null;
updated: string | null;
examples: string | null;
factor: number | null;
order: number | null;
}
/** Certificate Type (Jira Type ID: 407, 3 objects) */
export interface CertificateType extends BaseCMDBObject {
_objectType: 'CertificateType';
// Scalar attributes
key: string | null;
name: string | null; // The name of the object
created: string | null;
updated: string | null;
}
/** ApplicationFunctionCategory (Jira Type ID: 437, 13 objects) */
export interface ApplicationFunctionCategory extends BaseCMDBObject {
_objectType: 'ApplicationFunctionCategory';
// Scalar attributes
key: string | null;
name: string | null; // The name of the object
created: string | null;
updated: string | null;
description: string | null;
}
/** ApplicationFunction (Jira Type ID: 403, 93 objects) */
export interface ApplicationFunction extends BaseCMDBObject {
_objectType: 'ApplicationFunction';
// Scalar attributes
appFuncGUID: string | null;
key: string | null;
name: string | null; // The name of the object
description: string | null;
keywords: string | null;
created: string | null;
updated: string | null;
// Reference attributes
applicationFunctionCategory: ObjectReference | null; // -> ApplicationFunctionCategory
applicationManagementApplicationCluster: ObjectReference | null; // -> ApplicationManagementSubteam
}
/** Certificate ClassificationType (Jira Type ID: 408, 8 objects) */
export interface CertificateClassificationType extends BaseCMDBObject {
_objectType: 'CertificateClassificationType';
// Scalar attributes
key: string | null;
name: string | null; // The name of the object
description: string | null; // https://jira.zuyderland.nl/browse/CMDB-430
created: string | null;
updated: string | null;
}
/** Business Impact Analyse (Jira Type ID: 41, 6 objects) */
export interface BusinessImpactAnalyse extends BaseCMDBObject {
_objectType: 'BusinessImpactAnalyse';
// Scalar attributes
key: string | null;
name: string | null; // The name of the object
serviceWindow: string | null;
availabilty: string | null;
biaCheck: string | null;
recoveryTimeObjectiveRTO: string | null;
recoveryPointObjectiveRPO: string | null;
environments: string | null;
description: string | null;
indicators: string | null;
created: string | null;
updated: string | null;
}
/** Business Importance (Jira Type ID: 44, 8 objects) */
export interface BusinessImportance extends BaseCMDBObject {
_objectType: 'BusinessImportance';
// Scalar attributes
key: string | null;
name: string | null; // The name of the object
description: string | null;
created: string | null;
updated: string | null;
}
/** Hosting Type (Jira Type ID: 39, 6 objects) */
export interface HostingType extends BaseCMDBObject {
_objectType: 'HostingType';
// Scalar attributes
key: string | null;
name: string | null; // The name of the object
description: string | null;
created: string | null;
updated: string | null;
}
/** Organisation (Jira Type ID: 390, 6 objects) */
export interface Organisation extends BaseCMDBObject {
_objectType: 'Organisation';
// Scalar attributes
key: string | null;
name: string | null; // The name of the object
description: string | null;
created: string | null;
updated: string | null;
}
/** Application Management - Application Type (Jira Type ID: 436, 4 objects) */
export interface ApplicationManagementApplicationType extends BaseCMDBObject {
_objectType: 'ApplicationManagementApplicationType';
// Scalar attributes
key: string | null;
name: string | null; // The name of the object
created: string | null;
updated: string | null;
description: string | null;
}
/** Application Management - Dynamics Factor (Jira Type ID: 434, 4 objects) */
export interface ApplicationManagementDynamicsFactor extends BaseCMDBObject {
_objectType: 'ApplicationManagementDynamicsFactor';
// Scalar attributes
key: string | null;
name: string | null; // The name of the object
created: string | null;
updated: string | null;
summary: string | null;
description: string | null;
factor: number | null;
}
/** Application Management - Complexity Factor (Jira Type ID: 432, 4 objects) */
export interface ApplicationManagementComplexityFactor extends BaseCMDBObject {
_objectType: 'ApplicationManagementComplexityFactor';
// Scalar attributes
key: string | null;
name: string | null; // The name of the object
created: string | null;
updated: string | null;
summary: string | null;
description: string | null;
factor: number | null;
}
/** ICT Governance Model (Jira Type ID: 431, 6 objects) */
export interface IctGovernanceModel extends BaseCMDBObject {
_objectType: 'IctGovernanceModel';
// Scalar attributes
key: string | null;
name: string | null; // The name of the object
created: string | null;
updated: string | null;
summary: string | null;
description: string | null;
remarks: string | null;
application: string | null;
}
// =============================================================================
// Union Types
// =============================================================================
/** Union of all CMDB object types */
export type CMDBObject =
| ApplicationComponent
| Flows
| Server
| AzureSubscription
| PackageBuild
| Package
| Certificate
| PrivilegedUser
| Domain
| Supplier
| Software
| SoftwarePatch
| Manual
| Measures
| Rebootgroups
| ApplicationManagementTeam
| ApplicationManagementSubteam
| Assets
| CI
| Metadata
| ApplicationManagementHosting
| ApplicationManagementTam
| ApplicationManagementNumberOfUsers
| CertificateType
| ApplicationFunctionCategory
| ApplicationFunction
| CertificateClassificationType
| BusinessImpactAnalyse
| BusinessImportance
| HostingType
| Organisation
| ApplicationManagementApplicationType
| ApplicationManagementDynamicsFactor
| ApplicationManagementComplexityFactor
| IctGovernanceModel;
/** All valid object type names */
export type CMDBObjectTypeName =
| 'ApplicationComponent'
| 'Flows'
| 'Server'
| 'AzureSubscription'
| 'PackageBuild'
| 'Package'
| 'Certificate'
| 'PrivilegedUser'
| 'Domain'
| 'Supplier'
| 'Software'
| 'SoftwarePatch'
| 'Manual'
| 'Measures'
| 'Rebootgroups'
| 'ApplicationManagementTeam'
| 'ApplicationManagementSubteam'
| 'Assets'
| 'CI'
| 'Metadata'
| 'ApplicationManagementHosting'
| 'ApplicationManagementTam'
| 'ApplicationManagementNumberOfUsers'
| 'CertificateType'
| 'ApplicationFunctionCategory'
| 'ApplicationFunction'
| 'CertificateClassificationType'
| 'BusinessImpactAnalyse'
| 'BusinessImportance'
| 'HostingType'
| 'Organisation'
| 'ApplicationManagementApplicationType'
| 'ApplicationManagementDynamicsFactor'
| 'ApplicationManagementComplexityFactor'
| 'IctGovernanceModel';
// =============================================================================
// Type Guards
// =============================================================================
export function isApplicationComponent(obj: CMDBObject): obj is ApplicationComponent {
return obj._objectType === 'ApplicationComponent';
}
export function isFlows(obj: CMDBObject): obj is Flows {
return obj._objectType === 'Flows';
}
export function isServer(obj: CMDBObject): obj is Server {
return obj._objectType === 'Server';
}
export function isAzureSubscription(obj: CMDBObject): obj is AzureSubscription {
return obj._objectType === 'AzureSubscription';
}
export function isPackageBuild(obj: CMDBObject): obj is PackageBuild {
return obj._objectType === 'PackageBuild';
}
export function isPackage(obj: CMDBObject): obj is Package {
return obj._objectType === 'Package';
}
export function isCertificate(obj: CMDBObject): obj is Certificate {
return obj._objectType === 'Certificate';
}
export function isPrivilegedUser(obj: CMDBObject): obj is PrivilegedUser {
return obj._objectType === 'PrivilegedUser';
}
export function isDomain(obj: CMDBObject): obj is Domain {
return obj._objectType === 'Domain';
}
export function isSupplier(obj: CMDBObject): obj is Supplier {
return obj._objectType === 'Supplier';
}
export function isSoftware(obj: CMDBObject): obj is Software {
return obj._objectType === 'Software';
}
export function isSoftwarePatch(obj: CMDBObject): obj is SoftwarePatch {
return obj._objectType === 'SoftwarePatch';
}
export function isManual(obj: CMDBObject): obj is Manual {
return obj._objectType === 'Manual';
}
export function isMeasures(obj: CMDBObject): obj is Measures {
return obj._objectType === 'Measures';
}
export function isRebootgroups(obj: CMDBObject): obj is Rebootgroups {
return obj._objectType === 'Rebootgroups';
}
export function isApplicationManagementTeam(obj: CMDBObject): obj is ApplicationManagementTeam {
return obj._objectType === 'ApplicationManagementTeam';
}
export function isApplicationManagementSubteam(obj: CMDBObject): obj is ApplicationManagementSubteam {
return obj._objectType === 'ApplicationManagementSubteam';
}
export function isAssets(obj: CMDBObject): obj is Assets {
return obj._objectType === 'Assets';
}
export function isCI(obj: CMDBObject): obj is CI {
return obj._objectType === 'CI';
}
export function isMetadata(obj: CMDBObject): obj is Metadata {
return obj._objectType === 'Metadata';
}
export function isApplicationManagementHosting(obj: CMDBObject): obj is ApplicationManagementHosting {
return obj._objectType === 'ApplicationManagementHosting';
}
export function isApplicationManagementTam(obj: CMDBObject): obj is ApplicationManagementTam {
return obj._objectType === 'ApplicationManagementTam';
}
export function isApplicationManagementNumberOfUsers(obj: CMDBObject): obj is ApplicationManagementNumberOfUsers {
return obj._objectType === 'ApplicationManagementNumberOfUsers';
}
export function isCertificateType(obj: CMDBObject): obj is CertificateType {
return obj._objectType === 'CertificateType';
}
export function isApplicationFunctionCategory(obj: CMDBObject): obj is ApplicationFunctionCategory {
return obj._objectType === 'ApplicationFunctionCategory';
}
export function isApplicationFunction(obj: CMDBObject): obj is ApplicationFunction {
return obj._objectType === 'ApplicationFunction';
}
export function isCertificateClassificationType(obj: CMDBObject): obj is CertificateClassificationType {
return obj._objectType === 'CertificateClassificationType';
}
export function isBusinessImpactAnalyse(obj: CMDBObject): obj is BusinessImpactAnalyse {
return obj._objectType === 'BusinessImpactAnalyse';
}
export function isBusinessImportance(obj: CMDBObject): obj is BusinessImportance {
return obj._objectType === 'BusinessImportance';
}
export function isHostingType(obj: CMDBObject): obj is HostingType {
return obj._objectType === 'HostingType';
}
export function isOrganisation(obj: CMDBObject): obj is Organisation {
return obj._objectType === 'Organisation';
}
export function isApplicationManagementApplicationType(obj: CMDBObject): obj is ApplicationManagementApplicationType {
return obj._objectType === 'ApplicationManagementApplicationType';
}
export function isApplicationManagementDynamicsFactor(obj: CMDBObject): obj is ApplicationManagementDynamicsFactor {
return obj._objectType === 'ApplicationManagementDynamicsFactor';
}
export function isApplicationManagementComplexityFactor(obj: CMDBObject): obj is ApplicationManagementComplexityFactor {
return obj._objectType === 'ApplicationManagementComplexityFactor';
}
export function isIctGovernanceModel(obj: CMDBObject): obj is IctGovernanceModel {
return obj._objectType === 'IctGovernanceModel';
}

View File

@@ -6,13 +6,18 @@ import cookieParser from 'cookie-parser';
import { config, validateConfig } from './config/env.js'; import { config, validateConfig } from './config/env.js';
import { logger } from './services/logger.js'; import { logger } from './services/logger.js';
import { dataService } from './services/dataService.js'; import { dataService } from './services/dataService.js';
import { syncEngine } from './services/syncEngine.js';
import { cmdbService } from './services/cmdbService.js';
import applicationsRouter from './routes/applications.js'; import applicationsRouter from './routes/applications.js';
import classificationsRouter from './routes/classifications.js'; import classificationsRouter from './routes/classifications.js';
import referenceDataRouter from './routes/referenceData.js'; import referenceDataRouter from './routes/referenceData.js';
import dashboardRouter from './routes/dashboard.js'; import dashboardRouter from './routes/dashboard.js';
import configurationRouter from './routes/configuration.js'; import configurationRouter from './routes/configuration.js';
import authRouter, { authMiddleware } from './routes/auth.js'; import authRouter, { authMiddleware } from './routes/auth.js';
import { jiraAssetsService } from './services/jiraAssets.js'; import searchRouter from './routes/search.js';
import cacheRouter from './routes/cache.js';
import objectsRouter from './routes/objects.js';
import schemaRouter from './routes/schema.js';
// Validate configuration // Validate configuration
validateConfig(); validateConfig();
@@ -50,16 +55,16 @@ app.use((req, res, next) => {
// Auth middleware - extract session info for all requests // Auth middleware - extract session info for all requests
app.use(authMiddleware); app.use(authMiddleware);
// Set user token on JiraAssets service for each request // Set user token on CMDBService for each request (for user-specific OAuth)
app.use((req, res, next) => { app.use((req, res, next) => {
// Set user's OAuth token if available // Set user's OAuth token if available
if (req.accessToken) { if (req.accessToken) {
jiraAssetsService.setRequestToken(req.accessToken); cmdbService.setUserToken(req.accessToken);
} }
// Clear token after response is sent // Clear token after response is sent
res.on('finish', () => { res.on('finish', () => {
jiraAssetsService.clearRequestToken(); cmdbService.clearUserToken();
}); });
next(); next();
@@ -68,12 +73,19 @@ app.use((req, res, next) => {
// Health check // Health check
app.get('/health', async (req, res) => { app.get('/health', async (req, res) => {
const jiraConnected = await dataService.testConnection(); const jiraConnected = await dataService.testConnection();
const cacheStatus = dataService.getCacheStatus();
res.json({ res.json({
status: 'ok', status: 'ok',
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
dataSource: dataService.isUsingJiraAssets() ? 'jira-assets' : 'mock-data', dataSource: dataService.isUsingJiraAssets() ? 'jira-assets-cached' : 'mock-data',
jiraConnected: dataService.isUsingJiraAssets() ? jiraConnected : null, jiraConnected: dataService.isUsingJiraAssets() ? jiraConnected : null,
aiConfigured: !!config.anthropicApiKey, aiConfigured: !!config.anthropicApiKey,
cache: {
isWarm: cacheStatus.isWarm,
objectCount: cacheStatus.totalObjects,
lastSync: cacheStatus.lastIncrementalSync,
},
}); });
}); });
@@ -91,6 +103,10 @@ app.use('/api/classifications', classificationsRouter);
app.use('/api/reference-data', referenceDataRouter); app.use('/api/reference-data', referenceDataRouter);
app.use('/api/dashboard', dashboardRouter); app.use('/api/dashboard', dashboardRouter);
app.use('/api/configuration', configurationRouter); app.use('/api/configuration', configurationRouter);
app.use('/api/search', searchRouter);
app.use('/api/cache', cacheRouter);
app.use('/api/objects', objectsRouter);
app.use('/api/schema', schemaRouter);
// Error handling // Error handling
app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction) => { app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction) => {
@@ -108,20 +124,33 @@ app.use((req, res) => {
// Start server // Start server
const PORT = config.port; const PORT = config.port;
app.listen(PORT, () => { app.listen(PORT, async () => {
logger.info(`Server running on http://localhost:${PORT}`); logger.info(`Server running on http://localhost:${PORT}`);
logger.info(`Environment: ${config.nodeEnv}`); logger.info(`Environment: ${config.nodeEnv}`);
logger.info(`AI Classification: ${config.anthropicApiKey ? 'Configured' : 'Not configured'}`); logger.info(`AI Classification: ${config.anthropicApiKey ? 'Configured' : 'Not configured'}`);
logger.info(`Jira Assets: ${config.jiraPat ? 'Configured' : 'Using mock data'}`); logger.info(`Jira Assets: ${config.jiraPat ? 'Configured with caching' : 'Using mock data'}`);
// Initialize sync engine if using Jira Assets
if (config.jiraPat && config.jiraSchemaId) {
try {
await syncEngine.initialize();
logger.info('Sync Engine: Initialized and running');
} catch (error) {
logger.error('Failed to initialize sync engine', error);
}
}
}); });
// Graceful shutdown // Graceful shutdown
process.on('SIGTERM', () => { const shutdown = () => {
logger.info('SIGTERM signal received: closing HTTP server'); logger.info('Shutdown signal received: stopping services...');
// Stop sync engine
syncEngine.stop();
logger.info('Services stopped, exiting');
process.exit(0); process.exit(0);
}); };
process.on('SIGINT', () => { process.on('SIGTERM', shutdown);
logger.info('SIGINT signal received: closing HTTP server'); process.on('SIGINT', shutdown);
process.exit(0);
});

View File

@@ -1,9 +1,11 @@
import { Router, Request, Response } from 'express'; import { Router, Request, Response } from 'express';
import { dataService } from '../services/dataService.js'; import { dataService } from '../services/dataService.js';
import { databaseService } from '../services/database.js'; import { databaseService } from '../services/database.js';
import { cmdbService } from '../services/cmdbService.js';
import { logger } from '../services/logger.js'; import { logger } from '../services/logger.js';
import { calculateRequiredEffortApplicationManagement, calculateRequiredEffortApplicationManagementWithBreakdown } from '../services/effortCalculation.js'; import { calculateRequiredEffortApplicationManagementWithBreakdown } from '../services/effortCalculation.js';
import type { SearchFilters, ApplicationUpdateRequest, ReferenceValue, ClassificationResult, ApplicationDetails, ApplicationStatus } from '../types/index.js'; import type { SearchFilters, ReferenceValue, ClassificationResult, ApplicationDetails, ApplicationStatus } from '../types/index.js';
import type { Server, Flows, Certificate, Domain, AzureSubscription, CMDBObjectTypeName } from '../generated/jira-types.js';
const router = Router(); const router = Router();
@@ -50,9 +52,12 @@ router.get('/team-dashboard', async (req: Request, res: Response) => {
}); });
// Get application by ID // Get application by ID
// Query params:
// - mode=edit: Force refresh from Jira for editing (includes _jiraUpdatedAt for conflict detection)
router.get('/:id', async (req: Request, res: Response) => { router.get('/:id', async (req: Request, res: Response) => {
try { try {
const { id } = req.params; const { id } = req.params;
const mode = req.query.mode as string | undefined;
// Don't treat special routes as application IDs // Don't treat special routes as application IDs
if (id === 'team-dashboard' || id === 'calculate-effort' || id === 'search') { if (id === 'team-dashboard' || id === 'calculate-effort' || id === 'search') {
@@ -60,7 +65,10 @@ router.get('/:id', async (req: Request, res: Response) => {
return; return;
} }
const application = await dataService.getApplicationById(id); // Edit mode: force refresh from Jira for fresh data + conflict detection
const application = mode === 'edit'
? await dataService.getApplicationForEdit(id)
: await dataService.getApplicationById(id);
if (!application) { if (!application) {
res.status(404).json({ error: 'Application not found' }); res.status(404).json({ error: 'Application not found' });
@@ -74,19 +82,33 @@ router.get('/:id', async (req: Request, res: Response) => {
} }
}); });
// Update application // Update application with conflict detection
router.put('/:id', async (req: Request, res: Response) => { router.put('/:id', async (req: Request, res: Response) => {
try { try {
const { id } = req.params; const { id } = req.params;
const updates = req.body as { const { updates, _jiraUpdatedAt } = req.body as {
applicationFunctions?: ReferenceValue[]; updates?: {
dynamicsFactor?: ReferenceValue; applicationFunctions?: ReferenceValue[];
complexityFactor?: ReferenceValue; dynamicsFactor?: ReferenceValue;
numberOfUsers?: ReferenceValue; complexityFactor?: ReferenceValue;
governanceModel?: ReferenceValue; numberOfUsers?: ReferenceValue;
source?: 'AI_ACCEPTED' | 'AI_MODIFIED' | 'MANUAL'; governanceModel?: ReferenceValue;
applicationSubteam?: ReferenceValue;
applicationTeam?: ReferenceValue;
applicationType?: ReferenceValue;
hostingType?: ReferenceValue;
businessImpactAnalyse?: ReferenceValue;
overrideFTE?: number | null;
applicationManagementHosting?: string;
applicationManagementTAM?: string;
source?: 'AI_ACCEPTED' | 'AI_MODIFIED' | 'MANUAL';
};
_jiraUpdatedAt?: string;
}; };
// Support both new format (updates object) and legacy format (direct body)
const actualUpdates = updates || req.body;
const application = await dataService.getApplicationById(id); const application = await dataService.getApplicationById(id);
if (!application) { if (!application) {
res.status(404).json({ error: 'Application not found' }); res.status(404).json({ error: 'Application not found' });
@@ -95,61 +117,111 @@ router.put('/:id', async (req: Request, res: Response) => {
// Build changes object for history // Build changes object for history
const changes: ClassificationResult['changes'] = {}; const changes: ClassificationResult['changes'] = {};
if (updates.applicationFunctions) { if (actualUpdates.applicationFunctions) {
changes.applicationFunctions = { changes.applicationFunctions = {
from: application.applicationFunctions, from: application.applicationFunctions,
to: updates.applicationFunctions, to: actualUpdates.applicationFunctions,
}; };
} }
if (updates.dynamicsFactor) { if (actualUpdates.dynamicsFactor) {
changes.dynamicsFactor = { changes.dynamicsFactor = {
from: application.dynamicsFactor, from: application.dynamicsFactor,
to: updates.dynamicsFactor, to: actualUpdates.dynamicsFactor,
}; };
} }
if (updates.complexityFactor) { if (actualUpdates.complexityFactor) {
changes.complexityFactor = { changes.complexityFactor = {
from: application.complexityFactor, from: application.complexityFactor,
to: updates.complexityFactor, to: actualUpdates.complexityFactor,
}; };
} }
if (updates.numberOfUsers) { if (actualUpdates.numberOfUsers) {
changes.numberOfUsers = { changes.numberOfUsers = {
from: application.numberOfUsers, from: application.numberOfUsers,
to: updates.numberOfUsers, to: actualUpdates.numberOfUsers,
}; };
} }
if (updates.governanceModel) { if (actualUpdates.governanceModel) {
changes.governanceModel = { changes.governanceModel = {
from: application.governanceModel, from: application.governanceModel,
to: updates.governanceModel, to: actualUpdates.governanceModel,
}; };
} }
const success = await dataService.updateApplication(id, updates); // Call updateApplication with conflict detection if _jiraUpdatedAt is provided
const result = await dataService.updateApplication(id, actualUpdates, _jiraUpdatedAt);
if (success) { // Check for conflicts
// Save to classification history if (!result.success && result.conflict) {
const classificationResult: ClassificationResult = { // Return 409 Conflict with details
applicationId: id, res.status(409).json({
applicationName: application.name, status: 'conflict',
changes, message: 'Object is gewijzigd door iemand anders',
source: updates.source || 'MANUAL', conflicts: result.conflict.conflicts,
timestamp: new Date(), jiraUpdatedAt: result.conflict.jiraUpdatedAt,
}; canMerge: result.conflict.canMerge,
databaseService.saveClassificationResult(classificationResult); warning: result.conflict.warning,
actions: {
const updatedApp = await dataService.getApplicationById(id); forceOverwrite: true,
res.json(updatedApp); merge: result.conflict.canMerge || false,
} else { discard: true,
res.status(500).json({ error: 'Failed to update application' }); },
});
return;
} }
if (!result.success) {
res.status(500).json({ error: result.error || 'Failed to update application' });
return;
}
// Save to classification history
const classificationResult: ClassificationResult = {
applicationId: id,
applicationName: application.name,
changes,
source: actualUpdates.source || 'MANUAL',
timestamp: new Date(),
};
databaseService.saveClassificationResult(classificationResult);
// Return updated application
const updatedApp = result.data || await dataService.getApplicationById(id);
res.json(updatedApp);
} catch (error) { } catch (error) {
logger.error('Failed to update application', error); logger.error('Failed to update application', error);
res.status(500).json({ error: 'Failed to update application' }); res.status(500).json({ error: 'Failed to update application' });
} }
}); });
// Force update (ignore conflicts)
router.put('/:id/force', async (req: Request, res: Response) => {
try {
const { id } = req.params;
const updates = req.body;
const application = await dataService.getApplicationById(id);
if (!application) {
res.status(404).json({ error: 'Application not found' });
return;
}
// Force update without conflict check
const result = await dataService.updateApplication(id, updates);
if (!result.success) {
res.status(500).json({ error: result.error || 'Failed to update application' });
return;
}
const updatedApp = result.data || await dataService.getApplicationById(id);
res.json(updatedApp);
} catch (error) {
logger.error('Failed to force update application', error);
res.status(500).json({ error: 'Failed to force update application' });
}
});
// Calculate FTE effort for an application (real-time calculation without saving) // Calculate FTE effort for an application (real-time calculation without saving)
router.post('/calculate-effort', async (req: Request, res: Response) => { router.post('/calculate-effort', async (req: Request, res: Response) => {
try { try {
@@ -180,7 +252,8 @@ router.post('/calculate-effort', async (req: Request, res: Response) => {
complexityFactor: applicationData.complexityFactor || null, complexityFactor: applicationData.complexityFactor || null,
numberOfUsers: applicationData.numberOfUsers || null, numberOfUsers: applicationData.numberOfUsers || null,
governanceModel: applicationData.governanceModel || null, governanceModel: applicationData.governanceModel || null,
applicationCluster: applicationData.applicationCluster || null, applicationSubteam: applicationData.applicationSubteam || null,
applicationTeam: applicationData.applicationTeam || null,
applicationType: applicationData.applicationType || null, applicationType: applicationData.applicationType || null,
platform: applicationData.platform || null, platform: applicationData.platform || null,
requiredEffortApplicationManagement: null, requiredEffortApplicationManagement: null,
@@ -214,4 +287,120 @@ router.get('/:id/history', async (req: Request, res: Response) => {
} }
}); });
// Get related objects for an application (from cache)
router.get('/:id/related/:objectType', async (req: Request, res: Response) => {
try {
const { id, objectType } = req.params;
// Map object type string to CMDBObjectTypeName
const typeMap: Record<string, CMDBObjectTypeName> = {
'Server': 'Server',
'server': 'Server',
'Flows': 'Flows',
'flows': 'Flows',
'Flow': 'Flows',
'flow': 'Flows',
'Connection': 'Flows', // Frontend uses "Connection" for Flows
'connection': 'Flows',
'Certificate': 'Certificate',
'certificate': 'Certificate',
'Domain': 'Domain',
'domain': 'Domain',
'AzureSubscription': 'AzureSubscription',
'azuresubscription': 'AzureSubscription',
};
const typeName = typeMap[objectType];
if (!typeName) {
res.status(400).json({ error: `Unknown object type: ${objectType}` });
return;
}
// Use CMDBService to get related objects from cache
type RelatedObjectType = Server | Flows | Certificate | Domain | AzureSubscription;
let relatedObjects: RelatedObjectType[] = [];
switch (typeName) {
case 'Server':
relatedObjects = await cmdbService.getReferencingObjects<Server>(id, 'Server');
break;
case 'Flows': {
// Flows reference ApplicationComponents via Source and Target attributes
// We need to find Flows where this ApplicationComponent is the target of the reference
relatedObjects = await cmdbService.getReferencingObjects<Flows>(id, 'Flows');
break;
}
case 'Certificate':
relatedObjects = await cmdbService.getReferencingObjects<Certificate>(id, 'Certificate');
break;
case 'Domain':
relatedObjects = await cmdbService.getReferencingObjects<Domain>(id, 'Domain');
break;
case 'AzureSubscription':
relatedObjects = await cmdbService.getReferencingObjects<AzureSubscription>(id, 'AzureSubscription');
break;
default:
relatedObjects = [];
}
// Get requested attributes from query string
const requestedAttrs = req.query.attributes
? String(req.query.attributes).split(',').map(a => a.trim())
: [];
// Format response - must match RelatedObjectsResponse type expected by frontend
const objects = relatedObjects.map(obj => {
// Extract attributes from the object
const attributes: Record<string, string | null> = {};
const objData = obj as Record<string, unknown>;
// If specific attributes are requested, extract those
if (requestedAttrs.length > 0) {
for (const attrName of requestedAttrs) {
// Convert attribute name to camelCase field name
const fieldName = attrName.charAt(0).toLowerCase() + attrName.slice(1).replace(/\s+/g, '');
const value = objData[fieldName] ?? objData[attrName.toLowerCase()] ?? objData[attrName];
if (value === null || value === undefined) {
attributes[attrName] = null;
} else if (typeof value === 'object' && value !== null) {
// ObjectReference - extract label
const ref = value as { label?: string; name?: string; displayValue?: string };
attributes[attrName] = ref.label || ref.name || ref.displayValue || null;
} else {
attributes[attrName] = String(value);
}
}
} else {
// No specific attributes requested - include common ones
if ('status' in objData) {
const status = objData.status;
if (typeof status === 'object' && status !== null) {
attributes['Status'] = (status as { label?: string }).label || String(status);
} else if (status) {
attributes['Status'] = String(status);
}
}
if ('state' in objData) {
attributes['State'] = objData.state ? String(objData.state) : null;
}
}
return {
id: obj.id,
key: obj.objectKey,
label: obj.label,
name: obj.label,
objectType: obj._objectType,
attributes,
};
});
res.json({ objects, total: objects.length });
} catch (error) {
logger.error(`Failed to get related ${req.params.objectType} objects`, error);
res.status(500).json({ error: `Failed to get related objects` });
}
});
export default router; export default router;

View File

@@ -18,9 +18,14 @@ declare global {
// Get auth configuration // Get auth configuration
router.get('/config', (req: Request, res: Response) => { router.get('/config', (req: Request, res: Response) => {
const authMethod = authService.getAuthMethod();
res.json({ res.json({
// Configured authentication method ('pat', 'oauth', or 'none')
authMethod,
// Legacy fields for backward compatibility
oauthEnabled: authService.isOAuthEnabled(), oauthEnabled: authService.isOAuthEnabled(),
serviceAccountEnabled: authService.isUsingServiceAccount(), serviceAccountEnabled: authService.isUsingServiceAccount(),
// Jira host for display purposes
jiraHost: config.jiraHost, jiraHost: config.jiraHost,
}); });
}); });

135
backend/src/routes/cache.ts Normal file
View File

@@ -0,0 +1,135 @@
/**
* Cache management routes
*
* Provides endpoints for cache status and manual sync triggers.
*/
import { Router, Request, Response } from 'express';
import { cacheStore } from '../services/cacheStore.js';
import { syncEngine } from '../services/syncEngine.js';
import { logger } from '../services/logger.js';
import { OBJECT_TYPES } from '../generated/jira-schema.js';
import type { CMDBObjectTypeName } from '../generated/jira-types.js';
const router = Router();
// Get cache status
router.get('/status', (req: Request, res: Response) => {
try {
const cacheStats = cacheStore.getStats();
const syncStatus = syncEngine.getStatus();
res.json({
cache: cacheStats,
sync: syncStatus,
supportedTypes: Object.keys(OBJECT_TYPES),
});
} catch (error) {
logger.error('Failed to get cache status', error);
res.status(500).json({ error: 'Failed to get cache status' });
}
});
// Trigger full sync
router.post('/sync', async (req: Request, res: Response) => {
try {
logger.info('Manual full sync triggered');
// Don't wait for completion - return immediately
syncEngine.fullSync().catch(err => {
logger.error('Full sync failed', err);
});
res.json({
status: 'started',
message: 'Full sync started in background',
});
} catch (error) {
logger.error('Failed to trigger full sync', error);
res.status(500).json({ error: 'Failed to trigger sync' });
}
});
// Trigger sync for a specific object type
router.post('/sync/:objectType', async (req: Request, res: Response) => {
try {
const { objectType } = req.params;
// Validate object type
if (!OBJECT_TYPES[objectType]) {
res.status(400).json({
error: `Unknown object type: ${objectType}`,
supportedTypes: Object.keys(OBJECT_TYPES),
});
return;
}
logger.info(`Manual sync triggered for ${objectType}`);
const result = await syncEngine.syncType(objectType as CMDBObjectTypeName);
res.json({
status: 'completed',
objectType,
stats: result,
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to sync object type';
logger.error(`Failed to sync object type ${req.params.objectType}`, error);
// Return 409 (Conflict) if sync is already in progress, otherwise 500
const statusCode = errorMessage.includes('already in progress') ? 409 : 500;
res.status(statusCode).json({
error: errorMessage,
objectType: req.params.objectType,
});
}
});
// Clear cache for a specific type
router.delete('/clear/:objectType', (req: Request, res: Response) => {
try {
const { objectType } = req.params;
if (!OBJECT_TYPES[objectType]) {
res.status(400).json({
error: `Unknown object type: ${objectType}`,
supportedTypes: Object.keys(OBJECT_TYPES),
});
return;
}
logger.info(`Clearing cache for ${objectType}`);
const deleted = cacheStore.clearObjectType(objectType as CMDBObjectTypeName);
res.json({
status: 'cleared',
objectType,
deletedCount: deleted,
});
} catch (error) {
logger.error(`Failed to clear cache for ${req.params.objectType}`, error);
res.status(500).json({ error: 'Failed to clear cache' });
}
});
// Clear entire cache
router.delete('/clear', (req: Request, res: Response) => {
try {
logger.info('Clearing entire cache');
cacheStore.clearAll();
res.json({
status: 'cleared',
message: 'Entire cache cleared',
});
} catch (error) {
logger.error('Failed to clear cache', error);
res.status(500).json({ error: 'Failed to clear cache' });
}
});
export default router;

View File

@@ -1,10 +1,15 @@
import { Router, Request, Response } from 'express'; import { Router, Request, Response } from 'express';
import { readFile, writeFile } from 'fs/promises'; import { readFile, writeFile } from 'fs/promises';
import { join } from 'path'; import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { logger } from '../services/logger.js'; import { logger } from '../services/logger.js';
import { clearEffortCalculationConfigCache, getEffortCalculationConfigV25 } from '../services/effortCalculation.js'; import { clearEffortCalculationConfigCache, getEffortCalculationConfigV25 } from '../services/effortCalculation.js';
import type { EffortCalculationConfig, EffortCalculationConfigV25 } from '../config/effortCalculation.js'; import type { EffortCalculationConfig, EffortCalculationConfigV25 } from '../config/effortCalculation.js';
// Get __dirname equivalent for ES modules
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const router = Router(); const router = Router();
// Path to the configuration files // Path to the configuration files

View File

@@ -1,18 +1,21 @@
import { Router, Request, Response } from 'express'; import { Router, Request, Response } from 'express';
import { dataService } from '../services/dataService.js'; import { dataService } from '../services/dataService.js';
import { databaseService } from '../services/database.js'; import { databaseService } from '../services/database.js';
import { syncEngine } from '../services/syncEngine.js';
import { logger } from '../services/logger.js'; import { logger } from '../services/logger.js';
import { validateApplicationConfiguration } from '../services/effortCalculation.js';
import type { ApplicationDetails, ApplicationStatus, ReferenceValue } from '../types/index.js';
const router = Router(); const router = Router();
// Simple in-memory cache for dashboard stats // Simple in-memory cache for dashboard stats
interface CachedStats { interface CachedStats {
data: any; data: unknown;
timestamp: number; timestamp: number;
} }
let statsCache: CachedStats | null = null; let statsCache: CachedStats | null = null;
const CACHE_TTL_MS = 3 * 60 * 1000; // 3 minutes cache (longer since jiraAssets also caches) const CACHE_TTL_MS = 3 * 60 * 1000; // 3 minutes cache
// Get dashboard statistics // Get dashboard statistics
router.get('/stats', async (req: Request, res: Response) => { router.get('/stats', async (req: Request, res: Response) => {
@@ -24,7 +27,8 @@ router.get('/stats', async (req: Request, res: Response) => {
const now = Date.now(); const now = Date.now();
if (!forceRefresh && statsCache && (now - statsCache.timestamp) < CACHE_TTL_MS) { if (!forceRefresh && statsCache && (now - statsCache.timestamp) < CACHE_TTL_MS) {
logger.debug('Returning cached dashboard stats'); logger.debug('Returning cached dashboard stats');
return res.json(statsCache.data); res.json(statsCache.data);
return;
} }
logger.info('Dashboard: Fetching fresh stats...'); logger.info('Dashboard: Fetching fresh stats...');
@@ -33,10 +37,29 @@ router.get('/stats', async (req: Request, res: Response) => {
const includeDistributions = req.query.distributions !== 'false'; const includeDistributions = req.query.distributions !== 'false';
const stats = await dataService.getStats(includeDistributions); const stats = await dataService.getStats(includeDistributions);
const dbStats = databaseService.getStats(); const dbStats = databaseService.getStats();
// Get cache status
const cacheStatus = dataService.getCacheStatus();
const syncStatus = syncEngine.getStatus();
const responseData = { const responseData = {
...stats, ...stats,
classificationStats: dbStats, classificationStats: dbStats,
cache: {
lastFullSync: cacheStatus.lastFullSync,
lastIncrementalSync: cacheStatus.lastIncrementalSync,
objectCount: cacheStatus.totalObjects,
objectsByType: cacheStatus.objectsByType,
totalRelations: cacheStatus.totalRelations,
isWarm: cacheStatus.isWarm,
dbSizeBytes: cacheStatus.dbSizeBytes,
syncStatus: {
isRunning: syncStatus.isRunning,
isSyncing: syncStatus.isSyncing,
nextIncrementalSync: syncStatus.nextIncrementalSync,
incrementalInterval: syncStatus.incrementalInterval,
},
},
}; };
// Update cache // Update cache
@@ -53,11 +76,12 @@ router.get('/stats', async (req: Request, res: Response) => {
// Return cached data if available (even if expired) // Return cached data if available (even if expired)
if (statsCache) { if (statsCache) {
logger.info('Dashboard: Returning stale cached data due to error'); logger.info('Dashboard: Returning stale cached data due to error');
return res.json({ res.json({
...statsCache.data, ...statsCache.data as object,
stale: true, stale: true,
error: 'Using cached data due to API timeout', error: 'Using cached data due to API timeout',
}); });
return;
} }
res.status(500).json({ error: 'Failed to get dashboard stats' }); res.status(500).json({ error: 'Failed to get dashboard stats' });
@@ -76,4 +100,101 @@ router.get('/recent', (req: Request, res: Response) => {
} }
}); });
// Get applications with governance model validation issues
router.get('/governance-analysis', async (req: Request, res: Response) => {
try {
logger.info('Governance Analysis: Fetching all applications for validation...');
// Use batched fetching to avoid timeouts
const pageSize = 50; // Smaller batch size for reliability
// Include all statuses so they can be filtered client-side (including Closed)
const statuses: ApplicationStatus[] = ['In Production', 'Implementation', 'Proof of Concept', 'End of support', 'End of life', 'Deprecated', 'Shadow IT', 'Undefined', 'Closed'];
let allApplications: Array<{ id: string; key: string; name: string; status: ApplicationStatus | null; governanceModel?: ReferenceValue | null; applicationType?: ReferenceValue | null }> = [];
let currentPage = 1;
let totalCount = 0;
let hasMore = true;
// Fetch applications in batches
while (hasMore) {
try {
const searchResult = await dataService.searchApplications(
{ statuses },
currentPage,
pageSize
);
if (currentPage === 1) {
totalCount = searchResult.totalCount;
logger.info(`Governance Analysis: Total applications to process: ${totalCount}`);
}
allApplications = allApplications.concat(searchResult.applications as typeof allApplications);
hasMore = searchResult.applications.length === pageSize && allApplications.length < totalCount;
currentPage++;
// Safety limit to prevent infinite loops
if (currentPage > 100) {
logger.warn('Governance Analysis: Reached page limit, stopping fetch');
break;
}
} catch (fetchError) {
logger.error(`Governance Analysis: Error fetching page ${currentPage}`, fetchError);
// Continue with what we have if a single batch fails
hasMore = false;
}
}
logger.info(`Governance Analysis: Fetched ${allApplications.length} applications, validating...`);
const applicationsWithIssues: Array<{
id: string;
key: string;
name: string;
status: string | null;
governanceModel: string | null;
businessImpactAnalyse: string | null;
applicationType: string | null;
warnings: string[];
errors: string[];
}> = [];
// Process each application
for (const app of allApplications) {
// Get full application details for validation
const fullApp = await dataService.getApplicationById(app.id);
if (!fullApp) continue;
const validation = validateApplicationConfiguration(fullApp as ApplicationDetails);
// Only include applications with ERRORS (red warnings)
// Applications with only warnings (yellow) are excluded
if (validation.errors.length > 0) {
applicationsWithIssues.push({
id: app.id,
key: app.key,
name: app.name,
status: app.status,
governanceModel: app.governanceModel?.name || null,
businessImpactAnalyse: fullApp.businessImpactAnalyse?.name || null,
applicationType: app.applicationType?.name || null,
warnings: validation.warnings,
errors: validation.errors,
});
}
}
logger.info(`Governance Analysis: Found ${applicationsWithIssues.length} applications with validation issues`);
res.json({
totalApplications: totalCount,
applicationsWithIssues: applicationsWithIssues.length,
applications: applicationsWithIssues,
});
} catch (error) {
logger.error('Failed to get governance analysis', error);
res.status(500).json({ error: 'Failed to get governance analysis' });
}
});
export default router; export default router;

View File

@@ -0,0 +1,176 @@
/**
* Generic object routes
*
* Provides schema-driven access to all CMDB object types.
*/
import { Router, Request, Response } from 'express';
import { cmdbService } from '../services/cmdbService.js';
import { logger } from '../services/logger.js';
import { OBJECT_TYPES } from '../generated/jira-schema.js';
import type { CMDBObject, CMDBObjectTypeName } from '../generated/jira-types.js';
const router = Router();
// Get list of supported object types
router.get('/', (req: Request, res: Response) => {
const types = Object.entries(OBJECT_TYPES).map(([typeName, def]) => ({
typeName,
jiraTypeId: def.jiraTypeId,
name: def.name,
syncPriority: def.syncPriority,
attributeCount: def.attributes.length,
}));
res.json({
types,
totalTypes: types.length,
});
});
// Get all objects of a type
router.get('/:type', async (req: Request, res: Response) => {
try {
const { type } = req.params;
const limit = parseInt(req.query.limit as string) || 1000;
const offset = parseInt(req.query.offset as string) || 0;
const search = req.query.search as string | undefined;
// Validate type
if (!OBJECT_TYPES[type]) {
res.status(400).json({
error: `Unknown object type: ${type}`,
supportedTypes: Object.keys(OBJECT_TYPES),
});
return;
}
const objects = await cmdbService.getObjects(type as CMDBObjectTypeName, {
limit,
offset,
searchTerm: search,
});
const count = cmdbService.countObjects(type as CMDBObjectTypeName);
res.json({
objectType: type,
objects,
count: objects.length,
totalCount: count,
offset,
limit,
});
} catch (error) {
logger.error(`Failed to get objects of type ${req.params.type}`, error);
res.status(500).json({ error: 'Failed to get objects' });
}
});
// Get a specific object by ID
router.get('/:type/:id', async (req: Request, res: Response) => {
try {
const { type, id } = req.params;
const forceRefresh = req.query.refresh === 'true';
// Validate type
if (!OBJECT_TYPES[type]) {
res.status(400).json({
error: `Unknown object type: ${type}`,
supportedTypes: Object.keys(OBJECT_TYPES),
});
return;
}
const object = await cmdbService.getObject(type as CMDBObjectTypeName, id, {
forceRefresh,
});
if (!object) {
res.status(404).json({ error: 'Object not found' });
return;
}
res.json(object);
} catch (error) {
logger.error(`Failed to get object ${req.params.type}/${req.params.id}`, error);
res.status(500).json({ error: 'Failed to get object' });
}
});
// Get related objects
router.get('/:type/:id/related/:relationType', async (req: Request, res: Response) => {
try {
const { type, id, relationType } = req.params;
const attribute = req.query.attribute as string | undefined;
// Validate types
if (!OBJECT_TYPES[type]) {
res.status(400).json({ error: `Unknown source type: ${type}` });
return;
}
if (!OBJECT_TYPES[relationType]) {
res.status(400).json({ error: `Unknown relation type: ${relationType}` });
return;
}
const relatedObjects = await cmdbService.getRelatedObjects<CMDBObject>(
id,
attribute || '',
relationType as CMDBObjectTypeName
);
res.json({
sourceId: id,
sourceType: type,
relationType,
attribute: attribute || null,
objects: relatedObjects,
count: relatedObjects.length,
});
} catch (error) {
logger.error(`Failed to get related objects`, error);
res.status(500).json({ error: 'Failed to get related objects' });
}
});
// Get objects referencing this object (inbound references)
router.get('/:type/:id/referenced-by/:sourceType', async (req: Request, res: Response) => {
try {
const { type, id, sourceType } = req.params;
const attribute = req.query.attribute as string | undefined;
// Validate types
if (!OBJECT_TYPES[type]) {
res.status(400).json({ error: `Unknown target type: ${type}` });
return;
}
if (!OBJECT_TYPES[sourceType]) {
res.status(400).json({ error: `Unknown source type: ${sourceType}` });
return;
}
const referencingObjects = await cmdbService.getReferencingObjects<CMDBObject>(
id,
sourceType as CMDBObjectTypeName,
attribute
);
res.json({
targetId: id,
targetType: type,
sourceType,
attribute: attribute || null,
objects: referencingObjects,
count: referencingObjects.length,
});
} catch (error) {
logger.error(`Failed to get referencing objects`, error);
res.status(500).json({ error: 'Failed to get referencing objects' });
}
});
export default router;

View File

@@ -15,12 +15,14 @@ router.get('/', async (req: Request, res: Response) => {
organisations, organisations,
hostingTypes, hostingTypes,
applicationFunctions, applicationFunctions,
applicationClusters, applicationSubteams,
applicationTeams,
applicationTypes, applicationTypes,
businessImportance, businessImportance,
businessImpactAnalyses, businessImpactAnalyses,
applicationManagementHosting, applicationManagementHosting,
applicationManagementTAM, applicationManagementTAM,
subteamToTeamMapping,
] = await Promise.all([ ] = await Promise.all([
dataService.getDynamicsFactors(), dataService.getDynamicsFactors(),
dataService.getComplexityFactors(), dataService.getComplexityFactors(),
@@ -29,12 +31,14 @@ router.get('/', async (req: Request, res: Response) => {
dataService.getOrganisations(), dataService.getOrganisations(),
dataService.getHostingTypes(), dataService.getHostingTypes(),
dataService.getApplicationFunctions(), dataService.getApplicationFunctions(),
dataService.getApplicationClusters(), dataService.getApplicationSubteams(),
dataService.getApplicationTeams(),
dataService.getApplicationTypes(), dataService.getApplicationTypes(),
dataService.getBusinessImportance(), dataService.getBusinessImportance(),
dataService.getBusinessImpactAnalyses(), dataService.getBusinessImpactAnalyses(),
dataService.getApplicationManagementHosting(), dataService.getApplicationManagementHosting(),
dataService.getApplicationManagementTAM(), dataService.getApplicationManagementTAM(),
dataService.getSubteamToTeamMapping(),
]); ]);
res.json({ res.json({
@@ -45,12 +49,14 @@ router.get('/', async (req: Request, res: Response) => {
organisations, organisations,
hostingTypes, hostingTypes,
applicationFunctions, applicationFunctions,
applicationClusters, applicationSubteams,
applicationTeams,
applicationTypes, applicationTypes,
businessImportance, businessImportance,
businessImpactAnalyses, businessImpactAnalyses,
applicationManagementHosting, applicationManagementHosting,
applicationManagementTAM, applicationManagementTAM,
subteamToTeamMapping,
}); });
} catch (error) { } catch (error) {
logger.error('Failed to get reference data', error); logger.error('Failed to get reference data', error);
@@ -135,14 +141,25 @@ router.get('/application-functions', async (req: Request, res: Response) => {
} }
}); });
// Get application clusters (from Jira Assets) // Get application subteams (from Jira Assets)
router.get('/application-clusters', async (req: Request, res: Response) => { router.get('/application-subteams', async (req: Request, res: Response) => {
try { try {
const clusters = await dataService.getApplicationClusters(); const subteams = await dataService.getApplicationSubteams();
res.json(clusters); res.json(subteams);
} catch (error) { } catch (error) {
logger.error('Failed to get application clusters', error); logger.error('Failed to get application subteams', error);
res.status(500).json({ error: 'Failed to get application clusters' }); res.status(500).json({ error: 'Failed to get application subteams' });
}
});
// Get application teams (from Jira Assets)
router.get('/application-teams', async (req: Request, res: Response) => {
try {
const teams = await dataService.getApplicationTeams();
res.json(teams);
} catch (error) {
logger.error('Failed to get application teams', error);
res.status(500).json({ error: 'Failed to get application teams' });
} }
}); });

View File

@@ -0,0 +1,151 @@
import { Router } from 'express';
import { OBJECT_TYPES, SCHEMA_GENERATED_AT, SCHEMA_OBJECT_TYPE_COUNT, SCHEMA_TOTAL_ATTRIBUTES } from '../generated/jira-schema.js';
import type { ObjectTypeDefinition, AttributeDefinition } from '../generated/jira-schema.js';
const router = Router();
// Extended types for API response
interface ObjectTypeWithLinks extends ObjectTypeDefinition {
incomingLinks: Array<{
fromType: string;
fromTypeName: string;
attributeName: string;
isMultiple: boolean;
}>;
outgoingLinks: Array<{
toType: string;
toTypeName: string;
attributeName: string;
isMultiple: boolean;
}>;
}
interface SchemaResponse {
metadata: {
generatedAt: string;
objectTypeCount: number;
totalAttributes: number;
};
objectTypes: Record<string, ObjectTypeWithLinks>;
}
/**
* GET /api/schema
* Returns the complete Jira Assets schema with object types, attributes, and links
*/
router.get('/', (req, res) => {
try {
// Build links between object types
const objectTypesWithLinks: Record<string, ObjectTypeWithLinks> = {};
// First pass: convert all object types
for (const [typeName, typeDef] of Object.entries(OBJECT_TYPES)) {
objectTypesWithLinks[typeName] = {
...typeDef,
incomingLinks: [],
outgoingLinks: [],
};
}
// Second pass: build link relationships
for (const [typeName, typeDef] of Object.entries(OBJECT_TYPES)) {
for (const attr of typeDef.attributes) {
if (attr.type === 'reference' && attr.referenceTypeName) {
// Add outgoing link from this type
objectTypesWithLinks[typeName].outgoingLinks.push({
toType: attr.referenceTypeName,
toTypeName: OBJECT_TYPES[attr.referenceTypeName]?.name || attr.referenceTypeName,
attributeName: attr.name,
isMultiple: attr.isMultiple,
});
// Add incoming link to the referenced type
if (objectTypesWithLinks[attr.referenceTypeName]) {
objectTypesWithLinks[attr.referenceTypeName].incomingLinks.push({
fromType: typeName,
fromTypeName: typeDef.name,
attributeName: attr.name,
isMultiple: attr.isMultiple,
});
}
}
}
}
const response: SchemaResponse = {
metadata: {
generatedAt: SCHEMA_GENERATED_AT,
objectTypeCount: SCHEMA_OBJECT_TYPE_COUNT,
totalAttributes: SCHEMA_TOTAL_ATTRIBUTES,
},
objectTypes: objectTypesWithLinks,
};
res.json(response);
} catch (error) {
console.error('Failed to get schema:', error);
res.status(500).json({ error: 'Failed to get schema' });
}
});
/**
* GET /api/schema/object-type/:typeName
* Returns details for a specific object type
*/
router.get('/object-type/:typeName', (req, res) => {
const { typeName } = req.params;
const typeDef = OBJECT_TYPES[typeName];
if (!typeDef) {
return res.status(404).json({ error: `Object type '${typeName}' not found` });
}
// Build links for this specific type
const incomingLinks: Array<{
fromType: string;
fromTypeName: string;
attributeName: string;
isMultiple: boolean;
}> = [];
const outgoingLinks: Array<{
toType: string;
toTypeName: string;
attributeName: string;
isMultiple: boolean;
}> = [];
// Outgoing links from this type
for (const attr of typeDef.attributes) {
if (attr.type === 'reference' && attr.referenceTypeName) {
outgoingLinks.push({
toType: attr.referenceTypeName,
toTypeName: OBJECT_TYPES[attr.referenceTypeName]?.name || attr.referenceTypeName,
attributeName: attr.name,
isMultiple: attr.isMultiple,
});
}
}
// Incoming links from other types
for (const [otherTypeName, otherTypeDef] of Object.entries(OBJECT_TYPES)) {
for (const attr of otherTypeDef.attributes) {
if (attr.type === 'reference' && attr.referenceTypeName === typeName) {
incomingLinks.push({
fromType: otherTypeName,
fromTypeName: otherTypeDef.name,
attributeName: attr.name,
isMultiple: attr.isMultiple,
});
}
}
}
res.json({
...typeDef,
incomingLinks,
outgoingLinks,
});
});
export default router;

View File

@@ -0,0 +1,74 @@
import { Router, Request, Response } from 'express';
import { cmdbService } from '../services/cmdbService.js';
import { logger } from '../services/logger.js';
import { config } from '../config/env.js';
const router = Router();
// CMDB free-text search endpoint (from cache)
router.get('/', async (req: Request, res: Response) => {
try {
const query = req.query.query as string;
const limit = parseInt(req.query.limit as string, 10) || 100;
if (!query || query.trim().length === 0) {
res.status(400).json({ error: 'Query parameter is required' });
return;
}
logger.info(`CMDB search request: query="${query}", limit=${limit}`);
// Search all types in cache
const results = await cmdbService.searchAllTypes(query.trim(), { limit });
// Group results by object type
const objectTypeMap = new Map<string, { id: number; name: string; iconUrl: string }>();
const formattedResults = results.map(obj => {
const typeName = obj._objectType || 'Unknown';
// Track unique object types
if (!objectTypeMap.has(typeName)) {
objectTypeMap.set(typeName, {
id: objectTypeMap.size + 1,
name: typeName,
iconUrl: '', // Can be enhanced to include actual icons
});
}
const objectType = objectTypeMap.get(typeName)!;
return {
id: parseInt(obj.id, 10) || 0,
key: obj.objectKey,
label: obj.label,
objectTypeId: objectType.id,
avatarUrl: '',
attributes: [], // Can be enhanced to include attributes
};
});
// Build response matching CMDBSearchResponse interface
const response = {
metadata: {
count: formattedResults.length,
offset: 0,
limit: limit,
total: formattedResults.length,
criteria: {
query: query,
type: 'global',
schema: parseInt(config.jiraSchemaId, 10) || 0,
},
},
objectTypes: Array.from(objectTypeMap.values()),
results: formattedResults,
};
res.json(response);
} catch (error) {
logger.error('CMDB search failed', error);
res.status(500).json({ error: 'Failed to search CMDB' });
}
});
export default router;

View File

@@ -268,14 +268,21 @@ class AuthService {
return existed; return existed;
} }
// Check if OAuth is enabled // Check if OAuth is enabled (jiraAuthMethod = 'oauth')
isOAuthEnabled(): boolean { isOAuthEnabled(): boolean {
return config.jiraOAuthEnabled && !!config.jiraOAuthClientId && !!config.jiraOAuthClientSecret; return config.jiraAuthMethod === 'oauth' && !!config.jiraOAuthClientId && !!config.jiraOAuthClientSecret;
} }
// Check if using service account (PAT) fallback // Check if using service account (PAT) mode (jiraAuthMethod = 'pat')
isUsingServiceAccount(): boolean { isUsingServiceAccount(): boolean {
return !this.isOAuthEnabled() && !!config.jiraPat; return config.jiraAuthMethod === 'pat' && !!config.jiraPat;
}
// Get the configured authentication method
getAuthMethod(): 'pat' | 'oauth' | 'none' {
if (this.isOAuthEnabled()) return 'oauth';
if (this.isUsingServiceAccount()) return 'pat';
return 'none';
} }
} }

View File

@@ -0,0 +1,660 @@
/**
* CacheStore - SQLite cache operations for CMDB objects
*
* Provides fast local storage for CMDB data synced from Jira Assets.
* Uses the generated schema for type-safe operations.
*/
import Database from 'better-sqlite3';
import { join, dirname } from 'path';
import * as path from 'path';
import * as fs from 'fs';
import { logger } from './logger.js';
import type { CMDBObject, CMDBObjectTypeName, ObjectReference } from '../generated/jira-types.js';
import { getReferenceAttributes } from '../generated/jira-schema.js';
// Get current directory for ESM
const currentFileUrl = new URL(import.meta.url);
const __dirname = dirname(currentFileUrl.pathname);
const CACHE_DB_PATH = join(__dirname, '../../data/cmdb-cache.db');
export interface CacheStats {
totalObjects: number;
objectsByType: Record<string, number>;
totalRelations: number;
lastFullSync: string | null;
lastIncrementalSync: string | null;
isWarm: boolean;
dbSizeBytes: number;
}
export interface QueryOptions {
limit?: number;
offset?: number;
orderBy?: string;
orderDir?: 'ASC' | 'DESC';
}
class CacheStore {
private db: Database.Database;
private initialized: boolean = false;
constructor() {
// Ensure data directory exists
const dataDir = dirname(CACHE_DB_PATH);
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
this.db = new Database(CACHE_DB_PATH);
this.initialize();
}
private initialize(): void {
if (this.initialized) return;
// Read and execute the generated schema
const schemaPath = join(__dirname, '../generated/db-schema.sql');
if (fs.existsSync(schemaPath)) {
const schema = fs.readFileSync(schemaPath, 'utf-8');
this.db.exec(schema);
logger.info('CacheStore: Database schema initialized from generated file');
} else {
// Fallback: create tables directly
this.db.exec(`
CREATE TABLE IF NOT EXISTS cached_objects (
id TEXT PRIMARY KEY,
object_key TEXT NOT NULL UNIQUE,
object_type TEXT NOT NULL,
label TEXT NOT NULL,
data JSON NOT NULL,
jira_updated_at TEXT,
jira_created_at TEXT,
cached_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS object_relations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_id TEXT NOT NULL,
target_id TEXT NOT NULL,
attribute_name TEXT NOT NULL,
source_type TEXT NOT NULL,
target_type TEXT NOT NULL,
UNIQUE(source_id, target_id, attribute_name)
);
CREATE TABLE IF NOT EXISTS sync_metadata (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_objects_type ON cached_objects(object_type);
CREATE INDEX IF NOT EXISTS idx_objects_key ON cached_objects(object_key);
CREATE INDEX IF NOT EXISTS idx_objects_updated ON cached_objects(jira_updated_at);
CREATE INDEX IF NOT EXISTS idx_objects_label ON cached_objects(label);
CREATE INDEX IF NOT EXISTS idx_relations_source ON object_relations(source_id);
CREATE INDEX IF NOT EXISTS idx_relations_target ON object_relations(target_id);
CREATE INDEX IF NOT EXISTS idx_relations_source_type ON object_relations(source_type);
CREATE INDEX IF NOT EXISTS idx_relations_target_type ON object_relations(target_type);
CREATE INDEX IF NOT EXISTS idx_relations_attr ON object_relations(attribute_name);
`);
logger.info('CacheStore: Database schema initialized (fallback)');
}
this.initialized = true;
}
// ==========================================================================
// Object CRUD Operations
// ==========================================================================
/**
* Get a single object by ID
*/
getObject<T extends CMDBObject>(typeName: CMDBObjectTypeName, id: string): T | null {
const stmt = this.db.prepare(`
SELECT data FROM cached_objects
WHERE id = ? AND object_type = ?
`);
const row = stmt.get(id, typeName) as { data: string } | undefined;
if (!row) return null;
try {
return JSON.parse(row.data) as T;
} catch (error) {
logger.error(`CacheStore: Failed to parse object ${id}`, error);
return null;
}
}
/**
* Get a single object by object key (e.g., "ICMT-123")
*/
getObjectByKey<T extends CMDBObject>(typeName: CMDBObjectTypeName, objectKey: string): T | null {
const stmt = this.db.prepare(`
SELECT data FROM cached_objects
WHERE object_key = ? AND object_type = ?
`);
const row = stmt.get(objectKey, typeName) as { data: string } | undefined;
if (!row) return null;
try {
return JSON.parse(row.data) as T;
} catch (error) {
logger.error(`CacheStore: Failed to parse object ${objectKey}`, error);
return null;
}
}
/**
* Get all objects of a specific type
*/
getObjects<T extends CMDBObject>(
typeName: CMDBObjectTypeName,
options?: QueryOptions
): T[] {
const limit = options?.limit || 10000;
const offset = options?.offset || 0;
const orderBy = options?.orderBy || 'label';
const orderDir = options?.orderDir || 'ASC';
const stmt = this.db.prepare(`
SELECT data FROM cached_objects
WHERE object_type = ?
ORDER BY ${orderBy} ${orderDir}
LIMIT ? OFFSET ?
`);
const rows = stmt.all(typeName, limit, offset) as { data: string }[];
return rows.map(row => {
try {
return JSON.parse(row.data) as T;
} catch {
return null;
}
}).filter((obj): obj is T => obj !== null);
}
/**
* Count objects of a specific type
*/
countObjects(typeName: CMDBObjectTypeName): number {
const stmt = this.db.prepare(`
SELECT COUNT(*) as count FROM cached_objects
WHERE object_type = ?
`);
const row = stmt.get(typeName) as { count: number };
return row.count;
}
/**
* Search objects by label (case-insensitive)
*/
searchByLabel<T extends CMDBObject>(
typeName: CMDBObjectTypeName,
searchTerm: string,
options?: QueryOptions
): T[] {
const limit = options?.limit || 100;
const offset = options?.offset || 0;
const stmt = this.db.prepare(`
SELECT data FROM cached_objects
WHERE object_type = ? AND label LIKE ?
ORDER BY label ASC
LIMIT ? OFFSET ?
`);
const rows = stmt.all(typeName, `%${searchTerm}%`, limit, offset) as { data: string }[];
return rows.map(row => {
try {
return JSON.parse(row.data) as T;
} catch {
return null;
}
}).filter((obj): obj is T => obj !== null);
}
/**
* Search across all object types
*/
searchAllTypes(searchTerm: string, options?: QueryOptions): CMDBObject[] {
const limit = options?.limit || 100;
const offset = options?.offset || 0;
const stmt = this.db.prepare(`
SELECT data FROM cached_objects
WHERE label LIKE ? OR object_key LIKE ?
ORDER BY object_type, label ASC
LIMIT ? OFFSET ?
`);
const pattern = `%${searchTerm}%`;
const rows = stmt.all(pattern, pattern, limit, offset) as { data: string }[];
return rows.map(row => {
try {
return JSON.parse(row.data) as CMDBObject;
} catch {
return null;
}
}).filter((obj): obj is CMDBObject => obj !== null);
}
/**
* Upsert a single object
*/
upsertObject<T extends CMDBObject>(typeName: CMDBObjectTypeName, object: T): void {
const stmt = this.db.prepare(`
INSERT INTO cached_objects (id, object_key, object_type, label, data, jira_updated_at, jira_created_at, cached_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
object_key = excluded.object_key,
label = excluded.label,
data = excluded.data,
jira_updated_at = excluded.jira_updated_at,
cached_at = excluded.cached_at
`);
stmt.run(
object.id,
object.objectKey,
typeName,
object.label,
JSON.stringify(object),
object._jiraUpdatedAt || null,
object._jiraCreatedAt || null,
new Date().toISOString()
);
}
/**
* Batch upsert objects (much faster for bulk operations)
*/
batchUpsertObjects<T extends CMDBObject>(typeName: CMDBObjectTypeName, objects: T[]): void {
if (objects.length === 0) return;
const stmt = this.db.prepare(`
INSERT INTO cached_objects (id, object_key, object_type, label, data, jira_updated_at, jira_created_at, cached_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
object_key = excluded.object_key,
label = excluded.label,
data = excluded.data,
jira_updated_at = excluded.jira_updated_at,
cached_at = excluded.cached_at
`);
const now = new Date().toISOString();
const batchInsert = this.db.transaction((objs: T[]) => {
for (const obj of objs) {
stmt.run(
obj.id,
obj.objectKey,
typeName,
obj.label,
JSON.stringify(obj),
obj._jiraUpdatedAt || null,
obj._jiraCreatedAt || null,
now
);
}
});
batchInsert(objects);
logger.debug(`CacheStore: Batch upserted ${objects.length} ${typeName} objects`);
}
/**
* Delete an object by ID
*/
deleteObject(typeName: CMDBObjectTypeName, id: string): boolean {
const stmt = this.db.prepare(`
DELETE FROM cached_objects
WHERE id = ? AND object_type = ?
`);
const result = stmt.run(id, typeName);
// Also delete related relations
this.deleteRelationsForObject(id);
return result.changes > 0;
}
/**
* Clear all objects of a specific type
*/
clearObjectType(typeName: CMDBObjectTypeName): number {
// First get all IDs to delete relations
const idsStmt = this.db.prepare(`
SELECT id FROM cached_objects WHERE object_type = ?
`);
const ids = idsStmt.all(typeName) as { id: string }[];
// Delete relations
for (const { id } of ids) {
this.deleteRelationsForObject(id);
}
// Delete objects
const stmt = this.db.prepare(`
DELETE FROM cached_objects WHERE object_type = ?
`);
const result = stmt.run(typeName);
logger.info(`CacheStore: Cleared ${result.changes} ${typeName} objects`);
return result.changes;
}
/**
* Clear entire cache
*/
clearAll(): void {
this.db.exec('DELETE FROM cached_objects');
this.db.exec('DELETE FROM object_relations');
logger.info('CacheStore: Cleared all cached data');
}
// ==========================================================================
// Relation Operations
// ==========================================================================
/**
* Store a relation between two objects
*/
upsertRelation(
sourceId: string,
targetId: string,
attributeName: string,
sourceType: string,
targetType: string
): void {
const stmt = this.db.prepare(`
INSERT INTO object_relations (source_id, target_id, attribute_name, source_type, target_type)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(source_id, target_id, attribute_name) DO UPDATE SET
source_type = excluded.source_type,
target_type = excluded.target_type
`);
stmt.run(sourceId, targetId, attributeName, sourceType, targetType);
}
/**
* Batch upsert relations
*/
batchUpsertRelations(relations: Array<{
sourceId: string;
targetId: string;
attributeName: string;
sourceType: string;
targetType: string;
}>): void {
if (relations.length === 0) return;
const stmt = this.db.prepare(`
INSERT INTO object_relations (source_id, target_id, attribute_name, source_type, target_type)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(source_id, target_id, attribute_name) DO UPDATE SET
source_type = excluded.source_type,
target_type = excluded.target_type
`);
const batchInsert = this.db.transaction((rels: typeof relations) => {
for (const rel of rels) {
stmt.run(rel.sourceId, rel.targetId, rel.attributeName, rel.sourceType, rel.targetType);
}
});
batchInsert(relations);
logger.debug(`CacheStore: Batch upserted ${relations.length} relations`);
}
/**
* Get related objects (outbound references from an object)
*/
getRelatedObjects<T extends CMDBObject>(
sourceId: string,
targetTypeName: CMDBObjectTypeName,
attributeName?: string
): T[] {
let query = `
SELECT co.data FROM cached_objects co
JOIN object_relations rel ON co.id = rel.target_id
WHERE rel.source_id = ? AND co.object_type = ?
`;
const params: (string | undefined)[] = [sourceId, targetTypeName];
if (attributeName) {
query += ' AND rel.attribute_name = ?';
params.push(attributeName);
}
const stmt = this.db.prepare(query);
const rows = stmt.all(...params) as { data: string }[];
return rows.map(row => {
try {
return JSON.parse(row.data) as T;
} catch {
return null;
}
}).filter((obj): obj is T => obj !== null);
}
/**
* Get objects that reference the given object (inbound references)
*/
getReferencingObjects<T extends CMDBObject>(
targetId: string,
sourceTypeName: CMDBObjectTypeName,
attributeName?: string
): T[] {
let query = `
SELECT co.data FROM cached_objects co
JOIN object_relations rel ON co.id = rel.source_id
WHERE rel.target_id = ? AND co.object_type = ?
`;
const params: (string | undefined)[] = [targetId, sourceTypeName];
if (attributeName) {
query += ' AND rel.attribute_name = ?';
params.push(attributeName);
}
const stmt = this.db.prepare(query);
const rows = stmt.all(...params) as { data: string }[];
return rows.map(row => {
try {
return JSON.parse(row.data) as T;
} catch {
return null;
}
}).filter((obj): obj is T => obj !== null);
}
/**
* Delete all relations for an object
*/
deleteRelationsForObject(objectId: string): void {
const stmt = this.db.prepare(`
DELETE FROM object_relations
WHERE source_id = ? OR target_id = ?
`);
stmt.run(objectId, objectId);
}
/**
* Extract and store relations from an object based on its type schema
*/
extractAndStoreRelations<T extends CMDBObject>(typeName: CMDBObjectTypeName, object: T): void {
const refAttributes = getReferenceAttributes(typeName);
const relations: Array<{
sourceId: string;
targetId: string;
attributeName: string;
sourceType: string;
targetType: string;
}> = [];
for (const attrDef of refAttributes) {
const value = (object as unknown as Record<string, unknown>)[attrDef.fieldName];
if (!value) continue;
const targetType = attrDef.referenceTypeName || 'Unknown';
if (attrDef.isMultiple && Array.isArray(value)) {
for (const ref of value as ObjectReference[]) {
if (ref?.objectId) {
relations.push({
sourceId: object.id,
targetId: ref.objectId,
attributeName: attrDef.name,
sourceType: typeName,
targetType,
});
}
}
} else if (!attrDef.isMultiple) {
const ref = value as ObjectReference;
if (ref?.objectId) {
relations.push({
sourceId: object.id,
targetId: ref.objectId,
attributeName: attrDef.name,
sourceType: typeName,
targetType,
});
}
}
}
if (relations.length > 0) {
this.batchUpsertRelations(relations);
}
}
// ==========================================================================
// Sync Metadata Operations
// ==========================================================================
/**
* Get sync metadata value
*/
getSyncMetadata(key: string): string | null {
const stmt = this.db.prepare(`
SELECT value FROM sync_metadata WHERE key = ?
`);
const row = stmt.get(key) as { value: string } | undefined;
return row?.value || null;
}
/**
* Set sync metadata value
*/
setSyncMetadata(key: string, value: string): void {
const stmt = this.db.prepare(`
INSERT INTO sync_metadata (key, value, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET
value = excluded.value,
updated_at = excluded.updated_at
`);
stmt.run(key, value, new Date().toISOString());
}
/**
* Delete sync metadata
*/
deleteSyncMetadata(key: string): void {
const stmt = this.db.prepare(`
DELETE FROM sync_metadata WHERE key = ?
`);
stmt.run(key);
}
// ==========================================================================
// Statistics
// ==========================================================================
/**
* Get cache statistics
*/
getStats(): CacheStats {
// Count by type
const typeCountStmt = this.db.prepare(`
SELECT object_type, COUNT(*) as count
FROM cached_objects
GROUP BY object_type
`);
const typeCounts = typeCountStmt.all() as { object_type: string; count: number }[];
const objectsByType: Record<string, number> = {};
let totalObjects = 0;
for (const { object_type, count } of typeCounts) {
objectsByType[object_type] = count;
totalObjects += count;
}
// Count relations
const relCountStmt = this.db.prepare(`
SELECT COUNT(*) as count FROM object_relations
`);
const relCount = (relCountStmt.get() as { count: number }).count;
// Get sync metadata
const lastFullSync = this.getSyncMetadata('lastFullSync');
const lastIncrementalSync = this.getSyncMetadata('lastIncrementalSync');
// Check if cache is warm (has Application Components)
const isWarm = (objectsByType['ApplicationComponent'] || 0) > 0;
// Get database file size
let dbSizeBytes = 0;
try {
const stats = fs.statSync(CACHE_DB_PATH);
dbSizeBytes = stats.size;
} catch {
// Ignore
}
return {
totalObjects,
objectsByType,
totalRelations: relCount,
lastFullSync,
lastIncrementalSync,
isWarm,
dbSizeBytes,
};
}
/**
* Check if cache is warm (has data)
*/
isWarm(): boolean {
const count = this.countObjects('ApplicationComponent');
return count > 0;
}
/**
* Close database connection
*/
close(): void {
this.db.close();
}
}
// Export singleton instance
export const cacheStore = new CacheStore();

View File

@@ -372,7 +372,7 @@ async function formatApplicationFunctionsForPrompt(
return sections.join('\n\n'); return sections.join('\n\n');
} }
// Format reference objects for prompt (Application Type, Dynamics Factor, etc.) // Format reference objects for prompt (Application Type, etc.)
function formatReferenceObjectsForPrompt( function formatReferenceObjectsForPrompt(
objects: ReferenceValue[], objects: ReferenceValue[],
useSummary: boolean = false useSummary: boolean = false
@@ -391,10 +391,30 @@ function formatReferenceObjectsForPrompt(
.join('\n'); .join('\n');
} }
// Format factors (Dynamics/Complexity) with description for AI prompt
function formatFactorsForPrompt(objects: ReferenceValue[]): string {
if (objects.length === 0) {
return 'Geen factoren beschikbaar.';
}
return objects
.map((obj) => {
const parts: string[] = [` - ${obj.key}: ${obj.name}`];
if (obj.factor !== undefined) {
parts[0] += ` (factor: ${obj.factor})`;
}
if (obj.description) {
parts.push(` ${obj.description}`);
}
return parts.join('\n');
})
.join('\n');
}
// Format reference objects with emphasis on exact name (for fields where AI must use exact name) // Format reference objects with emphasis on exact name (for fields where AI must use exact name)
function formatReferenceObjectsWithExactNames( function formatReferenceObjectsWithExactNames(
objects: ReferenceValue[], objects: ReferenceValue[],
useSummary: boolean = false useDescription: boolean = false
): string { ): string {
if (objects.length === 0) { if (objects.length === 0) {
return 'Geen objecten beschikbaar.'; return 'Geen objecten beschikbaar.';
@@ -402,9 +422,7 @@ function formatReferenceObjectsWithExactNames(
return objects return objects
.map((obj) => { .map((obj) => {
const displayText = useSummary && obj.summary const displayText = useDescription && obj.description ? obj.description : '';
? obj.summary
: obj.description || '';
// Emphasize the exact name that should be used // Emphasize the exact name that should be used
return ` - **"${obj.name}"**${displayText ? ` - ${displayText}` : ''}`; return ` - **"${obj.name}"**${displayText ? ` - ${displayText}` : ''}`;
}) })
@@ -892,8 +910,8 @@ class AIService {
applicationFunctionCategories applicationFunctionCategories
); );
const applicationTypesFormatted = formatReferenceObjectsForPrompt(applicationTypes, false); const applicationTypesFormatted = formatReferenceObjectsForPrompt(applicationTypes, false);
const dynamicsFactorsFormatted = formatReferenceObjectsForPrompt(dynamicsFactors, true); const dynamicsFactorsFormatted = formatFactorsForPrompt(dynamicsFactors);
const complexityFactorsFormatted = formatReferenceObjectsForPrompt(complexityFactors, true); const complexityFactorsFormatted = formatFactorsForPrompt(complexityFactors);
const applicationManagementHostingFormatted = formatReferenceObjectsWithExactNames(applicationManagementHostingOptions, true); const applicationManagementHostingFormatted = formatReferenceObjectsWithExactNames(applicationManagementHostingOptions, true);
const businessImpactAnalysesFormatted = formatBusinessImpactAnalysesForPrompt(businessImpactAnalyses); const businessImpactAnalysesFormatted = formatBusinessImpactAnalysesForPrompt(businessImpactAnalyses);
const businessImpactIndicatorsFormatted = formatBusinessImpactIndicatorsForPrompt(businessImpactAnalyses); const businessImpactIndicatorsFormatted = formatBusinessImpactIndicatorsForPrompt(businessImpactAnalyses);
@@ -1133,8 +1151,8 @@ class AIService {
applicationFunctionCategories applicationFunctionCategories
); );
const applicationTypesFormatted = formatReferenceObjectsForPrompt(applicationTypes, false); const applicationTypesFormatted = formatReferenceObjectsForPrompt(applicationTypes, false);
const dynamicsFactorsFormatted = formatReferenceObjectsForPrompt(dynamicsFactors, true); const dynamicsFactorsFormatted = formatFactorsForPrompt(dynamicsFactors);
const complexityFactorsFormatted = formatReferenceObjectsForPrompt(complexityFactors, true); const complexityFactorsFormatted = formatFactorsForPrompt(complexityFactors);
const applicationManagementHostingFormatted = formatReferenceObjectsWithExactNames(applicationManagementHostingOptions, true); const applicationManagementHostingFormatted = formatReferenceObjectsWithExactNames(applicationManagementHostingOptions, true);
const businessImpactAnalysesFormatted = formatBusinessImpactAnalysesForPrompt(businessImpactAnalyses); const businessImpactAnalysesFormatted = formatBusinessImpactAnalysesForPrompt(businessImpactAnalyses);
const businessImpactIndicatorsFormatted = formatBusinessImpactIndicatorsForPrompt(businessImpactAnalyses); const businessImpactIndicatorsFormatted = formatBusinessImpactIndicatorsForPrompt(businessImpactAnalyses);

View File

@@ -0,0 +1,445 @@
/**
* CMDBService - Universal schema-driven CMDB service
*
* Provides a unified interface for all CMDB operations:
* - Reads from cache for fast access
* - Write-through to Jira with conflict detection
* - Schema-driven parsing and updates
*/
import { logger } from './logger.js';
import { cacheStore, type CacheStats } from './cacheStore.js';
import { jiraAssetsClient, type JiraUpdatePayload, JiraObjectNotFoundError } from './jiraAssetsClient.js';
import { conflictResolver, type ConflictCheckResult } from './conflictResolver.js';
import { OBJECT_TYPES, getAttributeDefinition } from '../generated/jira-schema.js';
import type { CMDBObject, CMDBObjectTypeName, ObjectReference } from '../generated/jira-types.js';
// =============================================================================
// Types
// =============================================================================
export interface GetObjectOptions {
/** Force refresh from Jira (bypasses cache) */
forceRefresh?: boolean;
}
export interface UpdateResult {
success: boolean;
data?: CMDBObject;
conflict?: ConflictCheckResult;
error?: string;
}
export interface SearchOptions {
limit?: number;
offset?: number;
searchTerm?: string;
}
// =============================================================================
// Service Implementation
// =============================================================================
class CMDBService {
// ==========================================================================
// Read Operations
// ==========================================================================
/**
* Get a single object by ID
* By default reads from cache; use forceRefresh to fetch from Jira
*/
async getObject<T extends CMDBObject>(
typeName: CMDBObjectTypeName,
id: string,
options?: GetObjectOptions
): Promise<T | null> {
// Force refresh: always fetch from Jira
if (options?.forceRefresh) {
return this.fetchAndCacheObject<T>(typeName, id);
}
// Try cache first
const cached = cacheStore.getObject<T>(typeName, id);
if (cached) {
return cached;
}
// Cache miss: fetch from Jira
return this.fetchAndCacheObject<T>(typeName, id);
}
/**
* Get a single object by object key (e.g., "ICMT-123")
*/
async getObjectByKey<T extends CMDBObject>(
typeName: CMDBObjectTypeName,
objectKey: string,
options?: GetObjectOptions
): Promise<T | null> {
// Force refresh: search Jira by key
if (options?.forceRefresh) {
const typeDef = OBJECT_TYPES[typeName];
if (!typeDef) return null;
const iql = `objectType = "${typeDef.name}" AND Key = "${objectKey}"`;
const result = await jiraAssetsClient.searchObjects(iql, 1, 1);
if (result.objects.length === 0) return null;
const parsed = jiraAssetsClient.parseObject<T>(result.objects[0]);
if (parsed) {
cacheStore.upsertObject(typeName, parsed);
cacheStore.extractAndStoreRelations(typeName, parsed);
}
return parsed;
}
// Try cache first
const cached = cacheStore.getObjectByKey<T>(typeName, objectKey);
if (cached) {
return cached;
}
// Cache miss: search Jira
return this.getObjectByKey(typeName, objectKey, { forceRefresh: true });
}
/**
* Fetch a single object from Jira and update cache
* If the object was deleted from Jira (404), it will be removed from the local cache
*/
private async fetchAndCacheObject<T extends CMDBObject>(
typeName: CMDBObjectTypeName,
id: string
): Promise<T | null> {
try {
const jiraObj = await jiraAssetsClient.getObject(id);
if (!jiraObj) return null;
const parsed = jiraAssetsClient.parseObject<T>(jiraObj);
if (parsed) {
cacheStore.upsertObject(typeName, parsed);
cacheStore.extractAndStoreRelations(typeName, parsed);
}
return parsed;
} catch (error) {
// If object was deleted from Jira, remove it from our cache
if (error instanceof JiraObjectNotFoundError) {
const deleted = cacheStore.deleteObject(typeName, id);
if (deleted) {
logger.info(`CMDBService: Removed deleted object ${typeName}/${id} from cache`);
}
return null;
}
// Re-throw other errors
throw error;
}
}
/**
* Get all objects of a type from cache
*/
async getObjects<T extends CMDBObject>(
typeName: CMDBObjectTypeName,
options?: SearchOptions
): Promise<T[]> {
if (options?.searchTerm) {
return cacheStore.searchByLabel<T>(typeName, options.searchTerm, {
limit: options.limit,
offset: options.offset,
});
}
return cacheStore.getObjects<T>(typeName, {
limit: options?.limit,
offset: options?.offset,
});
}
/**
* Count objects of a type in cache
*/
countObjects(typeName: CMDBObjectTypeName): number {
return cacheStore.countObjects(typeName);
}
/**
* Search across all object types
*/
async searchAllTypes(searchTerm: string, options?: { limit?: number }): Promise<CMDBObject[]> {
return cacheStore.searchAllTypes(searchTerm, { limit: options?.limit });
}
/**
* Get related objects (outbound references)
*/
async getRelatedObjects<T extends CMDBObject>(
sourceId: string,
attributeName: string,
targetTypeName: CMDBObjectTypeName
): Promise<T[]> {
return cacheStore.getRelatedObjects<T>(sourceId, targetTypeName, attributeName);
}
/**
* Get objects that reference the given object (inbound references)
*/
async getReferencingObjects<T extends CMDBObject>(
targetId: string,
sourceTypeName: CMDBObjectTypeName,
attributeName?: string
): Promise<T[]> {
return cacheStore.getReferencingObjects<T>(targetId, sourceTypeName, attributeName);
}
// ==========================================================================
// Write Operations
// ==========================================================================
/**
* Update an object with conflict detection
*
* @param typeName - The object type
* @param id - The object ID
* @param updates - Field updates (only changed fields)
* @param originalUpdatedAt - The _jiraUpdatedAt from when the object was loaded for editing
*/
async updateObject(
typeName: CMDBObjectTypeName,
id: string,
updates: Record<string, unknown>,
originalUpdatedAt: string
): Promise<UpdateResult> {
try {
// 1. Check for conflicts
const conflictResult = await conflictResolver.checkConflict(
typeName,
id,
originalUpdatedAt,
updates
);
if (conflictResult.hasConflict && conflictResult.conflicts && conflictResult.conflicts.length > 0) {
return {
success: false,
conflict: conflictResult,
};
}
// 2. Build Jira update payload
const payload = this.buildUpdatePayload(typeName, updates);
if (payload.attributes.length === 0) {
logger.warn(`CMDBService: No attributes to update for ${typeName} ${id}`);
return { success: true };
}
// 3. Send update to Jira
const success = await jiraAssetsClient.updateObject(id, payload);
if (!success) {
return {
success: false,
error: 'Failed to update object in Jira',
};
}
// 4. Fetch fresh data and update cache
const freshData = await this.fetchAndCacheObject(typeName, id);
logger.info(`CMDBService: Updated ${typeName} ${id}`);
return {
success: true,
data: freshData || undefined,
};
} catch (error) {
logger.error(`CMDBService: Update failed for ${typeName} ${id}`, error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
/**
* Force update without conflict check (use with caution)
*/
async forceUpdateObject(
typeName: CMDBObjectTypeName,
id: string,
updates: Record<string, unknown>
): Promise<UpdateResult> {
try {
const payload = this.buildUpdatePayload(typeName, updates);
if (payload.attributes.length === 0) {
return { success: true };
}
const success = await jiraAssetsClient.updateObject(id, payload);
if (!success) {
return {
success: false,
error: 'Failed to update object in Jira',
};
}
const freshData = await this.fetchAndCacheObject(typeName, id);
return {
success: true,
data: freshData || undefined,
};
} catch (error) {
logger.error(`CMDBService: Force update failed for ${typeName} ${id}`, error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
/**
* Build Jira update payload from field updates
*/
private buildUpdatePayload(
typeName: CMDBObjectTypeName,
updates: Record<string, unknown>
): JiraUpdatePayload {
const attributes: JiraUpdatePayload['attributes'] = [];
logger.debug(`CMDBService.buildUpdatePayload: Building payload for ${typeName}`, {
updateKeys: Object.keys(updates),
updates: JSON.stringify(updates, null, 2)
});
for (const [fieldName, value] of Object.entries(updates)) {
const attrDef = getAttributeDefinition(typeName, fieldName);
if (!attrDef) {
logger.warn(`CMDBService: Unknown attribute ${fieldName} for ${typeName}`);
continue;
}
if (!attrDef.isEditable) {
logger.warn(`CMDBService: Attribute ${fieldName} is not editable`);
continue;
}
const attrValues = this.buildAttributeValues(value, attrDef);
logger.debug(`CMDBService.buildUpdatePayload: Attribute ${fieldName} (jiraId: ${attrDef.jiraId}, type: ${attrDef.type}, isMultiple: ${attrDef.isMultiple})`, {
inputValue: JSON.stringify(value),
outputValues: JSON.stringify(attrValues)
});
attributes.push({
objectTypeAttributeId: attrDef.jiraId,
objectAttributeValues: attrValues,
});
}
logger.debug(`CMDBService.buildUpdatePayload: Final payload`, {
attributeCount: attributes.length,
payload: JSON.stringify({ attributes }, null, 2)
});
return { attributes };
}
/**
* Build attribute values for Jira API
*/
private buildAttributeValues(
value: unknown,
attrDef: { type: string; isMultiple: boolean }
): Array<{ value?: string }> {
// Null/undefined = clear the field
if (value === null || value === undefined) {
return [];
}
// Reference type
if (attrDef.type === 'reference') {
if (attrDef.isMultiple && Array.isArray(value)) {
return (value as ObjectReference[]).map(ref => ({
value: ref.objectKey,
}));
} else if (!attrDef.isMultiple) {
const ref = value as ObjectReference;
return [{ value: ref.objectKey }];
}
return [];
}
// Boolean
if (attrDef.type === 'boolean') {
return [{ value: value ? 'true' : 'false' }];
}
// Number types
if (attrDef.type === 'integer' || attrDef.type === 'float') {
return [{ value: String(value) }];
}
// String types
return [{ value: String(value) }];
}
// ==========================================================================
// Cache Management
// ==========================================================================
/**
* Get cache statistics
*/
getCacheStats(): CacheStats {
return cacheStore.getStats();
}
/**
* Check if cache has data
*/
isCacheWarm(): boolean {
return cacheStore.isWarm();
}
/**
* Clear cache for a specific type
*/
clearCacheForType(typeName: CMDBObjectTypeName): void {
cacheStore.clearObjectType(typeName);
}
/**
* Clear entire cache
*/
clearCache(): void {
cacheStore.clearAll();
}
// ==========================================================================
// User Token Management (for OAuth)
// ==========================================================================
/**
* Set user token for current request
*/
setUserToken(token: string | null): void {
jiraAssetsClient.setRequestToken(token);
}
/**
* Clear user token
*/
clearUserToken(): void {
jiraAssetsClient.clearRequestToken();
}
}
// Export singleton instance
export const cmdbService = new CMDBService();

View File

@@ -0,0 +1,254 @@
/**
* ConflictResolver - Detects and reports conflicts when updating CMDB objects
*
* Implements optimistic locking by comparing timestamps and checking
* for field-level conflicts.
*/
import { logger } from './logger.js';
import { jiraAssetsClient, JiraObjectNotFoundError } from './jiraAssetsClient.js';
import { OBJECT_TYPES, getAttributeDefinition } from '../generated/jira-schema.js';
import type { CMDBObjectTypeName } from '../generated/jira-types.js';
// =============================================================================
// Types
// =============================================================================
export interface FieldConflict {
field: string;
fieldName: string;
proposedValue: unknown;
jiraValue: unknown;
}
export interface ConflictCheckResult {
hasConflict: boolean;
conflicts?: FieldConflict[];
jiraUpdatedAt?: string;
warning?: string;
canMerge?: boolean;
}
// =============================================================================
// Conflict Resolver Implementation
// =============================================================================
class ConflictResolver {
/**
* Check for conflicts before updating an object
*
* @param typeName - The object type
* @param objectId - The object ID
* @param originalUpdatedAt - The _jiraUpdatedAt from when the user started editing
* @param proposedChanges - The changes the user wants to make
*/
async checkConflict(
typeName: CMDBObjectTypeName,
objectId: string,
originalUpdatedAt: string,
proposedChanges: Record<string, unknown>
): Promise<ConflictCheckResult> {
try {
// 1. Fetch current state from Jira
let jiraObj;
try {
jiraObj = await jiraAssetsClient.getObject(objectId);
} catch (error) {
if (error instanceof JiraObjectNotFoundError) {
return {
hasConflict: true,
warning: 'Object has been deleted from Jira',
};
}
throw error;
}
if (!jiraObj) {
return {
hasConflict: true,
warning: 'Object not found in Jira',
};
}
const currentUpdatedAt = jiraObj.updated || '';
// 2. Compare timestamps
if (currentUpdatedAt === originalUpdatedAt) {
// No changes since user started editing - safe to update
return { hasConflict: false };
}
// 3. Timestamp differs - check field-level conflicts
logger.info(`ConflictResolver: Timestamp mismatch for ${objectId}. Original: ${originalUpdatedAt}, Current: ${currentUpdatedAt}`);
const typeDef = OBJECT_TYPES[typeName];
if (!typeDef) {
return {
hasConflict: true,
warning: `Unknown object type: ${typeName}`,
};
}
const conflictingFields: FieldConflict[] = [];
for (const [fieldName, proposedValue] of Object.entries(proposedChanges)) {
const attrDef = getAttributeDefinition(typeName, fieldName);
if (!attrDef) continue;
// Find the attribute in Jira response
const jiraAttr = jiraObj.attributes.find(
a => a.objectTypeAttributeId === attrDef.jiraId ||
a.objectTypeAttribute?.name === attrDef.name
);
// Extract current value from Jira
const jiraValue = this.extractAttributeValue(jiraAttr, attrDef);
// Compare values
if (!this.valuesEqual(proposedValue, jiraValue, attrDef.type)) {
conflictingFields.push({
field: attrDef.name,
fieldName,
proposedValue,
jiraValue,
});
}
}
if (conflictingFields.length === 0) {
// Object was updated but not on the same fields - safe to merge
return {
hasConflict: false,
warning: 'Object was updated but no field conflicts detected',
jiraUpdatedAt: currentUpdatedAt,
canMerge: true,
};
}
// Real conflicts exist
return {
hasConflict: true,
conflicts: conflictingFields,
jiraUpdatedAt: currentUpdatedAt,
canMerge: false,
};
} catch (error) {
logger.error(`ConflictResolver: Error checking conflict for ${objectId}`, error);
return {
hasConflict: true,
warning: 'Error checking for conflicts',
};
}
}
/**
* Extract a typed value from a Jira attribute
*/
private extractAttributeValue(
jiraAttr: { objectAttributeValues: Array<{
value?: string;
displayValue?: string;
referencedObject?: { id: number; objectKey: string; label: string };
}> } | undefined,
attrDef: { type: string; isMultiple: boolean }
): unknown {
if (!jiraAttr?.objectAttributeValues?.length) {
return attrDef.isMultiple ? [] : null;
}
const values = jiraAttr.objectAttributeValues;
switch (attrDef.type) {
case 'reference': {
const refs = values
.filter(v => v.referencedObject)
.map(v => ({
objectId: v.referencedObject!.id.toString(),
objectKey: v.referencedObject!.objectKey,
label: v.referencedObject!.label,
}));
return attrDef.isMultiple ? refs : refs[0] || null;
}
case 'boolean': {
const val = values[0]?.value;
return val === 'true' || val === 'Ja';
}
case 'integer': {
const val = values[0]?.value;
return val ? parseInt(val, 10) : null;
}
case 'float': {
const val = values[0]?.value;
return val ? parseFloat(val) : null;
}
default:
return values[0]?.displayValue ?? values[0]?.value ?? null;
}
}
/**
* Compare two values for equality
*/
private valuesEqual(
proposed: unknown,
jira: unknown,
type: string
): boolean {
// Both null/undefined
if (proposed == null && jira == null) {
return true;
}
// One is null
if (proposed == null || jira == null) {
return false;
}
// Reference type
if (type === 'reference') {
return this.referencesEqual(proposed, jira);
}
// Arrays
if (Array.isArray(proposed) && Array.isArray(jira)) {
if (proposed.length !== jira.length) return false;
// For reference arrays, compare by objectId
if (type === 'reference') {
const proposedIds = new Set((proposed as Array<{ objectId?: string }>).map(r => r.objectId));
const jiraIds = new Set((jira as Array<{ objectId?: string }>).map(r => r.objectId));
if (proposedIds.size !== jiraIds.size) return false;
for (const id of proposedIds) {
if (!jiraIds.has(id)) return false;
}
return true;
}
return JSON.stringify(proposed) === JSON.stringify(jira);
}
// Primitives
return proposed === jira;
}
/**
* Compare reference values
*/
private referencesEqual(proposed: unknown, jira: unknown): boolean {
// Compare by objectId or objectKey
const propRef = proposed as { objectId?: string; objectKey?: string } | null;
const jiraRef = jira as { objectId?: string; objectKey?: string } | null;
if (!propRef && !jiraRef) return true;
if (!propRef || !jiraRef) return false;
return propRef.objectId === jiraRef.objectId ||
propRef.objectKey === jiraRef.objectKey;
}
}
// Export singleton instance
export const conflictResolver = new ConflictResolver();

File diff suppressed because it is too large Load Diff

View File

@@ -8,10 +8,15 @@ import {
HostingRule, HostingRule,
} from '../config/effortCalculation.js'; } from '../config/effortCalculation.js';
import { readFileSync, existsSync } from 'fs'; import { readFileSync, existsSync } from 'fs';
import { join } from 'path'; import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { logger } from './logger.js'; import { logger } from './logger.js';
import type { ApplicationDetails, EffortCalculationBreakdown } from '../types/index.js'; import type { ApplicationDetails, EffortCalculationBreakdown } from '../types/index.js';
// Get __dirname equivalent for ES modules
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Path to the configuration file (v25) // Path to the configuration file (v25)
const CONFIG_FILE_PATH_V25 = join(__dirname, '../../data/effort-calculation-config-v25.json'); const CONFIG_FILE_PATH_V25 = join(__dirname, '../../data/effort-calculation-config-v25.json');

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,425 @@
/**
* JiraAssetsClient - Low-level Jira Assets API client for CMDB caching
*
* This client handles direct API calls to Jira Insight/Assets and provides
* methods for fetching, parsing, and updating CMDB objects.
*/
import { config } from '../config/env.js';
import { logger } from './logger.js';
import { OBJECT_TYPES } from '../generated/jira-schema.js';
import type { CMDBObject, CMDBObjectTypeName, ObjectReference } from '../generated/jira-types.js';
import type { JiraAssetsObject, JiraAssetsAttribute, JiraAssetsSearchResponse } from '../types/index.js';
// =============================================================================
// Types
// =============================================================================
/** Error thrown when an object is not found in Jira (404) */
export class JiraObjectNotFoundError extends Error {
constructor(public objectId: string) {
super(`Object ${objectId} not found in Jira`);
this.name = 'JiraObjectNotFoundError';
}
}
export interface JiraUpdatePayload {
objectTypeId?: number; // Optional for updates (PUT) - only needed for creates (POST)
attributes: Array<{
objectTypeAttributeId: number;
objectAttributeValues: Array<{ value?: string }>; // value can be undefined when clearing
}>;
}
// Map from Jira object type ID to our type name
const TYPE_ID_TO_NAME: Record<number, CMDBObjectTypeName> = {};
const JIRA_NAME_TO_TYPE: Record<string, CMDBObjectTypeName> = {};
// Build lookup maps from schema
for (const [typeName, typeDef] of Object.entries(OBJECT_TYPES)) {
TYPE_ID_TO_NAME[typeDef.jiraTypeId] = typeName as CMDBObjectTypeName;
JIRA_NAME_TO_TYPE[typeDef.name] = typeName as CMDBObjectTypeName;
}
// =============================================================================
// JiraAssetsClient Implementation
// =============================================================================
class JiraAssetsClient {
private baseUrl: string;
private defaultHeaders: Record<string, string>;
private isDataCenter: boolean | null = null;
private requestToken: string | null = null;
constructor() {
this.baseUrl = `${config.jiraHost}/rest/insight/1.0`;
this.defaultHeaders = {
'Content-Type': 'application/json',
'Accept': 'application/json',
};
// Add PAT authentication if configured
if (config.jiraAuthMethod === 'pat' && config.jiraPat) {
this.defaultHeaders['Authorization'] = `Bearer ${config.jiraPat}`;
}
}
// ==========================================================================
// Request Token Management (for user-context requests)
// ==========================================================================
setRequestToken(token: string): void {
this.requestToken = token;
}
clearRequestToken(): void {
this.requestToken = null;
}
// ==========================================================================
// API Detection
// ==========================================================================
private async detectApiType(): Promise<void> {
if (this.isDataCenter !== null) return;
// Detect based on host URL pattern:
// - Jira Cloud uses *.atlassian.net domains
// - Everything else (custom domains) is Data Center / on-premise
if (config.jiraHost.includes('atlassian.net')) {
this.isDataCenter = false;
logger.info('JiraAssetsClient: Detected Jira Cloud (Assets API) based on host URL');
} else {
this.isDataCenter = true;
logger.info('JiraAssetsClient: Detected Jira Data Center (Insight API) based on host URL');
}
}
private getHeaders(): Record<string, string> {
const headers = { ...this.defaultHeaders };
// Use request-scoped token if available (for user context)
if (this.requestToken) {
headers['Authorization'] = `Bearer ${this.requestToken}`;
}
return headers;
}
// ==========================================================================
// Core API Methods
// ==========================================================================
private async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const url = `${this.baseUrl}${endpoint}`;
logger.debug(`JiraAssetsClient: ${options.method || 'GET'} ${url}`);
const response = await fetch(url, {
...options,
headers: {
...this.getHeaders(),
...options.headers,
},
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Jira API error ${response.status}: ${text}`);
}
return response.json() as Promise<T>;
}
// ==========================================================================
// Public API Methods
// ==========================================================================
async testConnection(): Promise<boolean> {
try {
await this.detectApiType();
const response = await fetch(`${this.baseUrl}/objectschema/${config.jiraSchemaId}`, {
headers: this.getHeaders(),
});
return response.ok;
} catch (error) {
logger.error('JiraAssetsClient: Connection test failed', error);
return false;
}
}
async getObject(objectId: string): Promise<JiraAssetsObject | null> {
try {
return await this.request<JiraAssetsObject>(`/object/${objectId}`);
} catch (error) {
// Check if this is a 404 (object not found / deleted)
if (error instanceof Error && error.message.includes('404')) {
logger.info(`JiraAssetsClient: Object ${objectId} not found in Jira (likely deleted)`);
throw new JiraObjectNotFoundError(objectId);
}
logger.error(`JiraAssetsClient: Failed to get object ${objectId}`, error);
return null;
}
}
async searchObjects(
iql: string,
page: number = 1,
pageSize: number = 50
): Promise<{ objects: JiraAssetsObject[]; totalCount: number; hasMore: boolean }> {
await this.detectApiType();
let response: JiraAssetsSearchResponse;
if (this.isDataCenter) {
// Try modern AQL endpoint first
try {
const params = new URLSearchParams({
qlQuery: iql,
page: page.toString(),
resultPerPage: pageSize.toString(),
includeAttributes: 'true',
includeAttributesDeep: '1',
objectSchemaId: config.jiraSchemaId,
});
response = await this.request<JiraAssetsSearchResponse>(`/aql/objects?${params.toString()}`);
} catch (error) {
// Fallback to deprecated IQL endpoint
logger.warn(`JiraAssetsClient: AQL endpoint failed, falling back to IQL: ${error}`);
const params = new URLSearchParams({
iql,
page: page.toString(),
resultPerPage: pageSize.toString(),
includeAttributes: 'true',
includeAttributesDeep: '1',
objectSchemaId: config.jiraSchemaId,
});
response = await this.request<JiraAssetsSearchResponse>(`/iql/objects?${params.toString()}`);
}
} else {
// Jira Cloud uses POST for AQL
response = await this.request<JiraAssetsSearchResponse>('/aql/objects', {
method: 'POST',
body: JSON.stringify({
qlQuery: iql,
page,
resultPerPage: pageSize,
includeAttributes: true,
}),
});
}
const totalCount = response.totalFilterCount || response.totalCount || 0;
const hasMore = response.objectEntries.length === pageSize && page * pageSize < totalCount;
return {
objects: response.objectEntries || [],
totalCount,
hasMore,
};
}
async getAllObjectsOfType(
typeName: CMDBObjectTypeName,
batchSize: number = 40
): Promise<JiraAssetsObject[]> {
const typeDef = OBJECT_TYPES[typeName];
if (!typeDef) {
logger.warn(`JiraAssetsClient: Unknown type ${typeName}`);
return [];
}
const allObjects: JiraAssetsObject[] = [];
let page = 1;
let hasMore = true;
while (hasMore) {
const iql = `objectType = "${typeDef.name}"`;
const result = await this.searchObjects(iql, page, batchSize);
allObjects.push(...result.objects);
hasMore = result.hasMore;
page++;
}
logger.info(`JiraAssetsClient: Fetched ${allObjects.length} ${typeName} objects`);
return allObjects;
}
async getUpdatedObjectsSince(
since: Date,
_batchSize: number = 40
): Promise<JiraAssetsObject[]> {
await this.detectApiType();
// Jira Data Center's IQL doesn't support filtering by 'updated' attribute
if (this.isDataCenter) {
logger.debug('JiraAssetsClient: Incremental sync via IQL not supported on Data Center, skipping');
return [];
}
// For Jira Cloud, we could use updated >= "date" in IQL
const iql = `updated >= "${since.toISOString()}"`;
const result = await this.searchObjects(iql, 1, 1000);
return result.objects;
}
async updateObject(objectId: string, payload: JiraUpdatePayload): Promise<boolean> {
try {
logger.info(`JiraAssetsClient.updateObject: Sending update for object ${objectId}`, {
attributeCount: payload.attributes.length,
payload: JSON.stringify(payload, null, 2)
});
await this.request(`/object/${objectId}`, {
method: 'PUT',
body: JSON.stringify(payload),
});
logger.info(`JiraAssetsClient.updateObject: Successfully updated object ${objectId}`);
return true;
} catch (error) {
logger.error(`JiraAssetsClient: Failed to update object ${objectId}`, error);
return false;
}
}
// ==========================================================================
// Object Parsing
// ==========================================================================
parseObject<T extends CMDBObject>(jiraObj: JiraAssetsObject): T | null {
const typeId = jiraObj.objectType?.id;
const typeName = TYPE_ID_TO_NAME[typeId] || JIRA_NAME_TO_TYPE[jiraObj.objectType?.name];
if (!typeName) {
logger.warn(`JiraAssetsClient: Unknown object type: ${jiraObj.objectType?.name} (ID: ${typeId})`);
return null;
}
const typeDef = OBJECT_TYPES[typeName];
if (!typeDef) {
return null;
}
const result: Record<string, unknown> = {
id: jiraObj.id.toString(),
objectKey: jiraObj.objectKey,
label: jiraObj.label,
_objectType: typeName,
_jiraUpdatedAt: jiraObj.updated || new Date().toISOString(),
_jiraCreatedAt: jiraObj.created || new Date().toISOString(),
};
// Parse each attribute based on schema
for (const attrDef of typeDef.attributes) {
const jiraAttr = this.findAttribute(jiraObj.attributes, attrDef.jiraId, attrDef.name);
result[attrDef.fieldName] = this.parseAttributeValue(jiraAttr, attrDef);
}
return result as T;
}
private findAttribute(
attributes: JiraAssetsAttribute[],
jiraId: number,
name: string
): JiraAssetsAttribute | undefined {
// Try by ID first
let attr = attributes.find(a => a.objectTypeAttributeId === jiraId);
if (attr) return attr;
// Try by name
attr = attributes.find(a =>
a.objectTypeAttribute?.name === name ||
a.objectTypeAttribute?.name?.toLowerCase() === name.toLowerCase()
);
return attr;
}
private parseAttributeValue(
jiraAttr: JiraAssetsAttribute | undefined,
attrDef: { type: string; isMultiple: boolean }
): unknown {
if (!jiraAttr?.objectAttributeValues?.length) {
return attrDef.isMultiple ? [] : null;
}
const values = jiraAttr.objectAttributeValues;
switch (attrDef.type) {
case 'reference': {
const refs = values
.filter(v => v.referencedObject)
.map(v => ({
objectId: v.referencedObject!.id.toString(),
objectKey: v.referencedObject!.objectKey,
label: v.referencedObject!.label,
} as ObjectReference));
return attrDef.isMultiple ? refs : refs[0] || null;
}
case 'text':
case 'textarea':
case 'url':
case 'email':
case 'select':
case 'user': {
const val = values[0]?.displayValue ?? values[0]?.value ?? null;
// Strip HTML if present
if (val && typeof val === 'string' && val.includes('<')) {
return this.stripHtml(val);
}
return val;
}
case 'integer': {
const val = values[0]?.value;
return val ? parseInt(val, 10) : null;
}
case 'float': {
const val = values[0]?.value;
return val ? parseFloat(val) : null;
}
case 'boolean': {
const val = values[0]?.value;
return val === 'true' || val === 'Ja';
}
case 'date':
case 'datetime': {
return values[0]?.value ?? values[0]?.displayValue ?? null;
}
case 'status': {
const statusVal = values[0]?.status;
if (statusVal) {
return statusVal.name || null;
}
return values[0]?.displayValue ?? values[0]?.value ?? null;
}
default:
return values[0]?.displayValue ?? values[0]?.value ?? null;
}
}
private stripHtml(html: string): string {
return html
.replace(/<[^>]*>/g, '')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/\s+/g, ' ')
.trim();
}
}
// Export singleton instance
export const jiraAssetsClient = new JiraAssetsClient();

View File

@@ -36,7 +36,8 @@ const mockApplications: ApplicationDetails[] = [
complexityFactor: { objectId: '4', key: 'CMP-4', name: '4 - Zeer hoog' }, complexityFactor: { objectId: '4', key: 'CMP-4', name: '4 - Zeer hoog' },
numberOfUsers: null, numberOfUsers: null,
governanceModel: { objectId: 'A', key: 'GOV-A', name: 'Centraal Beheer' }, governanceModel: { objectId: 'A', key: 'GOV-A', name: 'Centraal Beheer' },
applicationCluster: null, applicationSubteam: null,
applicationTeam: null,
applicationType: null, applicationType: null,
platform: null, platform: null,
requiredEffortApplicationManagement: null, requiredEffortApplicationManagement: null,
@@ -63,7 +64,8 @@ const mockApplications: ApplicationDetails[] = [
complexityFactor: { objectId: '3', key: 'CMP-3', name: '3 - Hoog' }, complexityFactor: { objectId: '3', key: 'CMP-3', name: '3 - Hoog' },
numberOfUsers: null, numberOfUsers: null,
governanceModel: null, governanceModel: null,
applicationCluster: null, applicationSubteam: null,
applicationTeam: null,
applicationType: null, applicationType: null,
platform: null, platform: null,
requiredEffortApplicationManagement: null, requiredEffortApplicationManagement: null,
@@ -90,7 +92,8 @@ const mockApplications: ApplicationDetails[] = [
complexityFactor: null, complexityFactor: null,
numberOfUsers: null, numberOfUsers: null,
governanceModel: { objectId: 'C', key: 'GOV-C', name: 'Uitbesteed met ICMT-Regie' }, governanceModel: { objectId: 'C', key: 'GOV-C', name: 'Uitbesteed met ICMT-Regie' },
applicationCluster: null, applicationSubteam: null,
applicationTeam: null,
applicationType: null, applicationType: null,
platform: null, platform: null,
requiredEffortApplicationManagement: null, requiredEffortApplicationManagement: null,
@@ -117,7 +120,8 @@ const mockApplications: ApplicationDetails[] = [
complexityFactor: { objectId: '4', key: 'CMP-4', name: '4 - Zeer hoog' }, complexityFactor: { objectId: '4', key: 'CMP-4', name: '4 - Zeer hoog' },
numberOfUsers: null, numberOfUsers: null,
governanceModel: { objectId: 'A', key: 'GOV-A', name: 'Centraal Beheer' }, governanceModel: { objectId: 'A', key: 'GOV-A', name: 'Centraal Beheer' },
applicationCluster: null, applicationSubteam: null,
applicationTeam: null,
applicationType: null, applicationType: null,
platform: null, platform: null,
requiredEffortApplicationManagement: null, requiredEffortApplicationManagement: null,
@@ -144,7 +148,8 @@ const mockApplications: ApplicationDetails[] = [
complexityFactor: { objectId: '2', key: 'CMP-2', name: '2 - Gemiddeld' }, complexityFactor: { objectId: '2', key: 'CMP-2', name: '2 - Gemiddeld' },
numberOfUsers: null, numberOfUsers: null,
governanceModel: null, governanceModel: null,
applicationCluster: null, applicationSubteam: null,
applicationTeam: null,
applicationType: null, applicationType: null,
platform: null, platform: null,
requiredEffortApplicationManagement: null, requiredEffortApplicationManagement: null,
@@ -171,7 +176,8 @@ const mockApplications: ApplicationDetails[] = [
complexityFactor: { objectId: '3', key: 'CMP-3', name: '3 - Hoog' }, complexityFactor: { objectId: '3', key: 'CMP-3', name: '3 - Hoog' },
numberOfUsers: { objectId: '7', key: 'USR-7', name: '> 15.000' }, numberOfUsers: { objectId: '7', key: 'USR-7', name: '> 15.000' },
governanceModel: { objectId: 'A', key: 'GOV-A', name: 'Centraal Beheer' }, governanceModel: { objectId: 'A', key: 'GOV-A', name: 'Centraal Beheer' },
applicationCluster: null, applicationSubteam: null,
applicationTeam: null,
applicationType: null, applicationType: null,
platform: null, platform: null,
requiredEffortApplicationManagement: null, requiredEffortApplicationManagement: null,
@@ -198,7 +204,8 @@ const mockApplications: ApplicationDetails[] = [
complexityFactor: { objectId: '2', key: 'CMP-2', name: '2 - Gemiddeld' }, complexityFactor: { objectId: '2', key: 'CMP-2', name: '2 - Gemiddeld' },
numberOfUsers: null, numberOfUsers: null,
governanceModel: null, governanceModel: null,
applicationCluster: null, applicationSubteam: null,
applicationTeam: null,
applicationType: null, applicationType: null,
platform: null, platform: null,
requiredEffortApplicationManagement: null, requiredEffortApplicationManagement: null,
@@ -225,7 +232,8 @@ const mockApplications: ApplicationDetails[] = [
complexityFactor: { objectId: '2', key: 'CMP-2', name: '2 - Gemiddeld' }, complexityFactor: { objectId: '2', key: 'CMP-2', name: '2 - Gemiddeld' },
numberOfUsers: { objectId: '6', key: 'USR-6', name: '10.000 - 15.000' }, numberOfUsers: { objectId: '6', key: 'USR-6', name: '10.000 - 15.000' },
governanceModel: { objectId: 'C', key: 'GOV-C', name: 'Uitbesteed met ICMT-Regie' }, governanceModel: { objectId: 'C', key: 'GOV-C', name: 'Uitbesteed met ICMT-Regie' },
applicationCluster: null, applicationSubteam: null,
applicationTeam: null,
applicationType: null, applicationType: null,
platform: null, platform: null,
requiredEffortApplicationManagement: null, requiredEffortApplicationManagement: null,
@@ -252,7 +260,8 @@ const mockApplications: ApplicationDetails[] = [
complexityFactor: { objectId: '1', key: 'CMP-1', name: '1 - Laag' }, complexityFactor: { objectId: '1', key: 'CMP-1', name: '1 - Laag' },
numberOfUsers: { objectId: '4', key: 'USR-4', name: '2.000 - 5.000' }, numberOfUsers: { objectId: '4', key: 'USR-4', name: '2.000 - 5.000' },
governanceModel: { objectId: 'C', key: 'GOV-C', name: 'Uitbesteed met ICMT-Regie' }, governanceModel: { objectId: 'C', key: 'GOV-C', name: 'Uitbesteed met ICMT-Regie' },
applicationCluster: null, applicationSubteam: null,
applicationTeam: null,
applicationType: null, applicationType: null,
platform: null, platform: null,
requiredEffortApplicationManagement: null, requiredEffortApplicationManagement: null,
@@ -279,7 +288,8 @@ const mockApplications: ApplicationDetails[] = [
complexityFactor: { objectId: '1', key: 'CMP-1', name: '1 - Laag' }, complexityFactor: { objectId: '1', key: 'CMP-1', name: '1 - Laag' },
numberOfUsers: { objectId: '1', key: 'USR-1', name: '< 100' }, numberOfUsers: { objectId: '1', key: 'USR-1', name: '< 100' },
governanceModel: { objectId: 'D', key: 'GOV-D', name: 'Uitbesteed met Business-Regie' }, governanceModel: { objectId: 'D', key: 'GOV-D', name: 'Uitbesteed met Business-Regie' },
applicationCluster: null, applicationSubteam: null,
applicationTeam: null,
applicationType: null, applicationType: null,
platform: null, platform: null,
requiredEffortApplicationManagement: null, requiredEffortApplicationManagement: null,
@@ -347,10 +357,10 @@ const mockBusinessImpactAnalyses: ReferenceValue[] = [
{ objectId: '9', key: 'BIA-9', name: 'BIA-2022-0089 (Klasse C)' }, { objectId: '9', key: 'BIA-9', name: 'BIA-2022-0089 (Klasse C)' },
]; ];
const mockApplicationClusters: ReferenceValue[] = [ const mockApplicationSubteams: ReferenceValue[] = [
{ objectId: '1', key: 'CLUSTER-1', name: 'Zorgapplicaties' }, { objectId: '1', key: 'SUBTEAM-1', name: 'Zorgapplicaties' },
{ objectId: '2', key: 'CLUSTER-2', name: 'Bedrijfsvoering' }, { objectId: '2', key: 'SUBTEAM-2', name: 'Bedrijfsvoering' },
{ objectId: '3', key: 'CLUSTER-3', name: 'Infrastructuur' }, { objectId: '3', key: 'SUBTEAM-3', name: 'Infrastructuur' },
]; ];
const mockApplicationTypes: ReferenceValue[] = [ const mockApplicationTypes: ReferenceValue[] = [
@@ -420,11 +430,11 @@ export class MockDataService {
filtered = filtered.filter((app) => !!app.complexityFactor); filtered = filtered.filter((app) => !!app.complexityFactor);
} }
// Apply applicationCluster filter // Apply applicationSubteam filter
if (filters.applicationCluster === 'empty') { if (filters.applicationSubteam === 'empty') {
filtered = filtered.filter((app) => !app.applicationCluster); filtered = filtered.filter((app) => !app.applicationSubteam);
} else if (filters.applicationCluster === 'filled') { } else if (filters.applicationSubteam === 'filled') {
filtered = filtered.filter((app) => !!app.applicationCluster); filtered = filtered.filter((app) => !!app.applicationSubteam);
} }
// Apply applicationType filter // Apply applicationType filter
@@ -468,7 +478,8 @@ export class MockDataService {
governanceModel: app.governanceModel, governanceModel: app.governanceModel,
dynamicsFactor: app.dynamicsFactor, dynamicsFactor: app.dynamicsFactor,
complexityFactor: app.complexityFactor, complexityFactor: app.complexityFactor,
applicationCluster: app.applicationCluster, applicationSubteam: app.applicationSubteam,
applicationTeam: app.applicationTeam,
applicationType: app.applicationType, applicationType: app.applicationType,
platform: app.platform, platform: app.platform,
requiredEffortApplicationManagement: effort, requiredEffortApplicationManagement: effort,
@@ -501,7 +512,8 @@ export class MockDataService {
complexityFactor?: ReferenceValue; complexityFactor?: ReferenceValue;
numberOfUsers?: ReferenceValue; numberOfUsers?: ReferenceValue;
governanceModel?: ReferenceValue; governanceModel?: ReferenceValue;
applicationCluster?: ReferenceValue; applicationSubteam?: ReferenceValue;
applicationTeam?: ReferenceValue;
applicationType?: ReferenceValue; applicationType?: ReferenceValue;
hostingType?: ReferenceValue; hostingType?: ReferenceValue;
businessImpactAnalyse?: ReferenceValue; businessImpactAnalyse?: ReferenceValue;
@@ -527,8 +539,11 @@ export class MockDataService {
if (updates.governanceModel !== undefined) { if (updates.governanceModel !== undefined) {
app.governanceModel = updates.governanceModel; app.governanceModel = updates.governanceModel;
} }
if (updates.applicationCluster !== undefined) { if (updates.applicationSubteam !== undefined) {
app.applicationCluster = updates.applicationCluster; app.applicationSubteam = updates.applicationSubteam;
}
if (updates.applicationTeam !== undefined) {
app.applicationTeam = updates.applicationTeam;
} }
if (updates.applicationType !== undefined) { if (updates.applicationType !== undefined) {
app.applicationType = updates.applicationType; app.applicationType = updates.applicationType;
@@ -539,12 +554,6 @@ export class MockDataService {
if (updates.businessImpactAnalyse !== undefined) { if (updates.businessImpactAnalyse !== undefined) {
app.businessImpactAnalyse = updates.businessImpactAnalyse; app.businessImpactAnalyse = updates.businessImpactAnalyse;
} }
if (updates.applicationCluster !== undefined) {
app.applicationCluster = updates.applicationCluster;
}
if (updates.applicationType !== undefined) {
app.applicationType = updates.applicationType;
}
return true; return true;
} }
@@ -601,7 +610,7 @@ export class MockDataService {
return []; return [];
} }
async getApplicationClusters(): Promise<ReferenceValue[]> { async getApplicationSubteams(): Promise<ReferenceValue[]> {
// Return empty for mock - in real implementation, this comes from Jira // Return empty for mock - in real implementation, this comes from Jira
return []; return [];
} }
@@ -671,7 +680,8 @@ export class MockDataService {
governanceModel: app.governanceModel, governanceModel: app.governanceModel,
dynamicsFactor: app.dynamicsFactor, dynamicsFactor: app.dynamicsFactor,
complexityFactor: app.complexityFactor, complexityFactor: app.complexityFactor,
applicationCluster: app.applicationCluster, applicationSubteam: app.applicationSubteam,
applicationTeam: app.applicationTeam,
applicationType: app.applicationType, applicationType: app.applicationType,
platform: app.platform, platform: app.platform,
requiredEffortApplicationManagement: app.requiredEffortApplicationManagement, requiredEffortApplicationManagement: app.requiredEffortApplicationManagement,
@@ -726,8 +736,8 @@ export class MockDataService {
}); });
} }
// Group all applications (regular + platforms + workloads) by cluster // Group all applications (regular + platforms + workloads) by subteam
const clusterMap = new Map<string, { const subteamMap = new Map<string, {
regular: ApplicationListItem[]; regular: ApplicationListItem[];
platforms: import('../types/index.js').PlatformWithWorkloads[]; platforms: import('../types/index.js').PlatformWithWorkloads[];
}>(); }>();
@@ -739,39 +749,39 @@ export class MockDataService {
platforms: [], platforms: [],
}; };
// Group regular applications by cluster // Group regular applications by subteam
for (const app of regularApplications) { for (const app of regularApplications) {
if (app.applicationCluster) { if (app.applicationSubteam) {
const clusterId = app.applicationCluster.objectId; const subteamId = app.applicationSubteam.objectId;
if (!clusterMap.has(clusterId)) { if (!subteamMap.has(subteamId)) {
clusterMap.set(clusterId, { regular: [], platforms: [] }); subteamMap.set(subteamId, { regular: [], platforms: [] });
} }
clusterMap.get(clusterId)!.regular.push(app); subteamMap.get(subteamId)!.regular.push(app);
} else { } else {
unassigned.regular.push(app); unassigned.regular.push(app);
} }
} }
// Group platforms by cluster // Group platforms by subteam
for (const platformWithWorkloads of platformsWithWorkloads) { for (const platformWithWorkloads of platformsWithWorkloads) {
const platform = platformWithWorkloads.platform; const platform = platformWithWorkloads.platform;
if (platform.applicationCluster) { if (platform.applicationSubteam) {
const clusterId = platform.applicationCluster.objectId; const subteamId = platform.applicationSubteam.objectId;
if (!clusterMap.has(clusterId)) { if (!subteamMap.has(subteamId)) {
clusterMap.set(clusterId, { regular: [], platforms: [] }); subteamMap.set(subteamId, { regular: [], platforms: [] });
} }
clusterMap.get(clusterId)!.platforms.push(platformWithWorkloads); subteamMap.get(subteamId)!.platforms.push(platformWithWorkloads);
} else { } else {
unassigned.platforms.push(platformWithWorkloads); unassigned.platforms.push(platformWithWorkloads);
} }
} }
// Get all clusters // Build subteams from mock data
const allClusters = mockApplicationClusters; const allSubteams = mockApplicationSubteams;
const clusters = allClusters.map(cluster => { const subteams: import('../types/index.js').TeamDashboardSubteam[] = allSubteams.map(subteamRef => {
const clusterData = clusterMap.get(cluster.objectId) || { regular: [], platforms: [] }; const subteamData = subteamMap.get(subteamRef.objectId) || { regular: [], platforms: [] };
const regularApps = clusterData.regular; const regularApps = subteamData.regular;
const platforms = clusterData.platforms; const platforms = subteamData.platforms;
// Calculate total effort: regular apps + platforms (including their workloads) // Calculate total effort: regular apps + platforms (including their workloads)
const regularEffort = regularApps.reduce((sum, app) => const regularEffort = regularApps.reduce((sum, app) =>
@@ -803,7 +813,7 @@ export class MockDataService {
} }
return { return {
cluster, subteam: subteamRef,
applications: regularApps, applications: regularApps,
platforms, platforms,
totalEffort, totalEffort,
@@ -812,7 +822,28 @@ export class MockDataService {
applicationCount, applicationCount,
byGovernanceModel, byGovernanceModel,
}; };
}); }).filter(s => s.applicationCount > 0); // Only include subteams with apps
// Create a virtual team containing all subteams (since Team doesn't exist in mock data)
const virtualTeam: import('../types/index.js').TeamDashboardTeam = {
team: {
objectId: 'mock-team-1',
key: 'TEAM-1',
name: 'Mock Team',
teamType: 'Business',
},
subteams,
totalEffort: subteams.reduce((sum, s) => sum + s.totalEffort, 0),
minEffort: subteams.reduce((sum, s) => sum + s.minEffort, 0),
maxEffort: subteams.reduce((sum, s) => sum + s.maxEffort, 0),
applicationCount: subteams.reduce((sum, s) => sum + s.applicationCount, 0),
byGovernanceModel: subteams.reduce((acc, s) => {
for (const [key, count] of Object.entries(s.byGovernanceModel)) {
acc[key] = (acc[key] || 0) + count;
}
return acc;
}, {} as Record<string, number>),
};
// Calculate unassigned totals // Calculate unassigned totals
const unassignedRegularEffort = unassigned.regular.reduce((sum, app) => const unassignedRegularEffort = unassigned.regular.reduce((sum, app) =>
@@ -842,8 +873,9 @@ export class MockDataService {
} }
return { return {
clusters, teams: subteams.length > 0 ? [virtualTeam] : [],
unassigned: { unassigned: {
subteam: null,
applications: unassigned.regular, applications: unassigned.regular,
platforms: unassigned.platforms, platforms: unassigned.platforms,
totalEffort: unassignedTotalEffort, totalEffort: unassignedTotalEffort,

View File

@@ -0,0 +1,463 @@
/**
* SyncEngine - Background synchronization service
*
* Handles:
* - Full sync at startup and periodically
* - Incremental sync every 30 seconds
* - Schema-driven sync for all object types
*/
import { logger } from './logger.js';
import { cacheStore } from './cacheStore.js';
import { jiraAssetsClient, JiraObjectNotFoundError } from './jiraAssetsClient.js';
import { OBJECT_TYPES, getObjectTypesBySyncPriority } from '../generated/jira-schema.js';
import type { CMDBObject, CMDBObjectTypeName } from '../generated/jira-types.js';
// =============================================================================
// Types
// =============================================================================
export interface SyncStats {
objectType: string;
objectsProcessed: number;
relationsExtracted: number;
duration: number;
}
export interface SyncResult {
success: boolean;
stats: SyncStats[];
totalObjects: number;
totalRelations: number;
duration: number;
error?: string;
}
export interface SyncEngineStatus {
isRunning: boolean;
isSyncing: boolean;
lastFullSync: string | null;
lastIncrementalSync: string | null;
nextIncrementalSync: string | null;
incrementalInterval: number;
}
// =============================================================================
// Configuration
// =============================================================================
const DEFAULT_INCREMENTAL_INTERVAL = 30_000; // 30 seconds
const DEFAULT_BATCH_SIZE = 50;
// =============================================================================
// Sync Engine Implementation
// =============================================================================
class SyncEngine {
private isRunning: boolean = false;
private isSyncing: boolean = false; // For full/incremental syncs
private syncingTypes: Set<CMDBObjectTypeName> = new Set(); // Track which types are being synced
private incrementalTimer: NodeJS.Timeout | null = null;
private incrementalInterval: number;
private batchSize: number;
private lastIncrementalSync: Date | null = null;
constructor() {
this.incrementalInterval = parseInt(
process.env.SYNC_INCREMENTAL_INTERVAL_MS || String(DEFAULT_INCREMENTAL_INTERVAL),
10
);
this.batchSize = parseInt(
process.env.JIRA_API_BATCH_SIZE || String(DEFAULT_BATCH_SIZE),
10
);
}
// ==========================================================================
// Lifecycle
// ==========================================================================
/**
* Initialize the sync engine
* Performs initial sync if cache is cold, then starts incremental sync
*/
async initialize(): Promise<void> {
if (this.isRunning) {
logger.warn('SyncEngine: Already running');
return;
}
logger.info('SyncEngine: Initializing...');
this.isRunning = true;
// Check if we need a full sync
const stats = cacheStore.getStats();
const lastFullSync = stats.lastFullSync;
const needsFullSync = !stats.isWarm || !lastFullSync || this.isStale(lastFullSync, 24 * 60 * 60 * 1000);
if (needsFullSync) {
logger.info('SyncEngine: Cache is cold or stale, starting full sync in background...');
// Run full sync in background (non-blocking)
this.fullSync().catch(err => {
logger.error('SyncEngine: Background full sync failed', err);
});
} else {
logger.info('SyncEngine: Cache is warm, skipping initial full sync');
}
// Start incremental sync scheduler
this.startIncrementalSyncScheduler();
logger.info('SyncEngine: Initialized');
}
/**
* Stop the sync engine
*/
stop(): void {
logger.info('SyncEngine: Stopping...');
this.isRunning = false;
if (this.incrementalTimer) {
clearInterval(this.incrementalTimer);
this.incrementalTimer = null;
}
logger.info('SyncEngine: Stopped');
}
/**
* Check if a timestamp is stale
*/
private isStale(timestamp: string, maxAgeMs: number): boolean {
const age = Date.now() - new Date(timestamp).getTime();
return age > maxAgeMs;
}
// ==========================================================================
// Full Sync
// ==========================================================================
/**
* Perform a full sync of all object types
*/
async fullSync(): Promise<SyncResult> {
if (this.isSyncing) {
logger.warn('SyncEngine: Sync already in progress');
return {
success: false,
stats: [],
totalObjects: 0,
totalRelations: 0,
duration: 0,
error: 'Sync already in progress',
};
}
this.isSyncing = true;
const startTime = Date.now();
const stats: SyncStats[] = [];
let totalObjects = 0;
let totalRelations = 0;
logger.info('SyncEngine: Starting full sync...');
try {
// Get object types sorted by sync priority
const objectTypes = getObjectTypesBySyncPriority();
for (const typeDef of objectTypes) {
const typeStat = await this.syncObjectType(typeDef.typeName as CMDBObjectTypeName);
stats.push(typeStat);
totalObjects += typeStat.objectsProcessed;
totalRelations += typeStat.relationsExtracted;
}
// Update sync metadata
const now = new Date().toISOString();
cacheStore.setSyncMetadata('lastFullSync', now);
cacheStore.setSyncMetadata('lastIncrementalSync', now);
this.lastIncrementalSync = new Date();
const duration = Date.now() - startTime;
logger.info(`SyncEngine: Full sync complete. ${totalObjects} objects, ${totalRelations} relations in ${duration}ms`);
return {
success: true,
stats,
totalObjects,
totalRelations,
duration,
};
} catch (error) {
logger.error('SyncEngine: Full sync failed', error);
return {
success: false,
stats,
totalObjects,
totalRelations,
duration: Date.now() - startTime,
error: error instanceof Error ? error.message : 'Unknown error',
};
} finally {
this.isSyncing = false;
}
}
/**
* Sync a single object type
*/
private async syncObjectType(typeName: CMDBObjectTypeName): Promise<SyncStats> {
const startTime = Date.now();
let objectsProcessed = 0;
let relationsExtracted = 0;
try {
const typeDef = OBJECT_TYPES[typeName];
if (!typeDef) {
logger.warn(`SyncEngine: Unknown type ${typeName}`);
return { objectType: typeName, objectsProcessed: 0, relationsExtracted: 0, duration: 0 };
}
logger.debug(`SyncEngine: Syncing ${typeName}...`);
// Fetch all objects from Jira
const jiraObjects = await jiraAssetsClient.getAllObjectsOfType(typeName, this.batchSize);
// Parse and cache objects
const parsedObjects: CMDBObject[] = [];
for (const jiraObj of jiraObjects) {
const parsed = jiraAssetsClient.parseObject(jiraObj);
if (parsed) {
parsedObjects.push(parsed);
}
}
// Batch upsert to cache
if (parsedObjects.length > 0) {
cacheStore.batchUpsertObjects(typeName, parsedObjects);
objectsProcessed = parsedObjects.length;
// Extract relations
for (const obj of parsedObjects) {
cacheStore.extractAndStoreRelations(typeName, obj);
relationsExtracted++;
}
}
const duration = Date.now() - startTime;
logger.debug(`SyncEngine: Synced ${objectsProcessed} ${typeName} objects in ${duration}ms`);
return {
objectType: typeName,
objectsProcessed,
relationsExtracted,
duration,
};
} catch (error) {
logger.error(`SyncEngine: Failed to sync ${typeName}`, error);
return {
objectType: typeName,
objectsProcessed,
relationsExtracted,
duration: Date.now() - startTime,
};
}
}
// ==========================================================================
// Incremental Sync
// ==========================================================================
/**
* Start the incremental sync scheduler
*/
private startIncrementalSyncScheduler(): void {
if (this.incrementalTimer) {
clearInterval(this.incrementalTimer);
}
logger.info(`SyncEngine: Starting incremental sync scheduler (every ${this.incrementalInterval}ms)`);
this.incrementalTimer = setInterval(() => {
if (!this.isSyncing && this.isRunning) {
this.incrementalSync().catch(err => {
logger.error('SyncEngine: Incremental sync failed', err);
});
}
}, this.incrementalInterval);
}
/**
* Perform an incremental sync (only updated objects)
*
* Note: On Jira Data Center, IQL-based incremental sync is not supported.
* We instead check if a periodic full sync is needed.
*/
async incrementalSync(): Promise<{ success: boolean; updatedCount: number }> {
if (this.isSyncing) {
return { success: false, updatedCount: 0 };
}
this.isSyncing = true;
try {
// Get the last sync time
const lastSyncStr = cacheStore.getSyncMetadata('lastIncrementalSync');
const since = lastSyncStr
? new Date(lastSyncStr)
: new Date(Date.now() - 60000); // Default: last minute
logger.debug(`SyncEngine: Incremental sync since ${since.toISOString()}`);
// Fetch updated objects from Jira
const updatedObjects = await jiraAssetsClient.getUpdatedObjectsSince(since, this.batchSize);
// If no objects returned (e.g., Data Center doesn't support IQL incremental sync),
// check if we should trigger a full sync instead
if (updatedObjects.length === 0) {
const lastFullSyncStr = cacheStore.getSyncMetadata('lastFullSync');
if (lastFullSyncStr) {
const lastFullSync = new Date(lastFullSyncStr);
const fullSyncAge = Date.now() - lastFullSync.getTime();
const FULL_SYNC_INTERVAL = 15 * 60 * 1000; // 15 minutes
if (fullSyncAge > FULL_SYNC_INTERVAL) {
logger.info('SyncEngine: Triggering periodic full sync (incremental not available)');
// Release the lock before calling fullSync
this.isSyncing = false;
await this.fullSync();
return { success: true, updatedCount: 0 };
}
}
// Update timestamp even if no objects were synced
const now = new Date();
cacheStore.setSyncMetadata('lastIncrementalSync', now.toISOString());
this.lastIncrementalSync = now;
return { success: true, updatedCount: 0 };
}
let updatedCount = 0;
for (const jiraObj of updatedObjects) {
const parsed = jiraAssetsClient.parseObject(jiraObj);
if (parsed) {
const typeName = parsed._objectType as CMDBObjectTypeName;
cacheStore.upsertObject(typeName, parsed);
cacheStore.extractAndStoreRelations(typeName, parsed);
updatedCount++;
}
}
// Update sync metadata
const now = new Date();
cacheStore.setSyncMetadata('lastIncrementalSync', now.toISOString());
this.lastIncrementalSync = now;
if (updatedCount > 0) {
logger.info(`SyncEngine: Incremental sync updated ${updatedCount} objects`);
}
return { success: true, updatedCount };
} catch (error) {
logger.error('SyncEngine: Incremental sync failed', error);
return { success: false, updatedCount: 0 };
} finally {
this.isSyncing = false;
}
}
// ==========================================================================
// Manual Sync Triggers
// ==========================================================================
/**
* Trigger a sync for a specific object type
* Allows concurrent syncs for different types, but blocks if:
* - A full sync is in progress
* - An incremental sync is in progress
* - This specific type is already being synced
*/
async syncType(typeName: CMDBObjectTypeName): Promise<SyncStats> {
// Block if a full or incremental sync is running
if (this.isSyncing) {
throw new Error('Full or incremental sync already in progress');
}
// Block if this specific type is already being synced
if (this.syncingTypes.has(typeName)) {
throw new Error(`Sync already in progress for ${typeName}`);
}
this.syncingTypes.add(typeName);
try {
return await this.syncObjectType(typeName);
} finally {
this.syncingTypes.delete(typeName);
}
}
/**
* Force sync a single object
* If the object was deleted from Jira, it will be removed from the local cache
*/
async syncObject(typeName: CMDBObjectTypeName, objectId: string): Promise<boolean> {
try {
const jiraObj = await jiraAssetsClient.getObject(objectId);
if (!jiraObj) return false;
const parsed = jiraAssetsClient.parseObject(jiraObj);
if (!parsed) return false;
cacheStore.upsertObject(typeName, parsed);
cacheStore.extractAndStoreRelations(typeName, parsed);
return true;
} catch (error) {
// If object was deleted from Jira, remove it from our cache
if (error instanceof JiraObjectNotFoundError) {
const deleted = cacheStore.deleteObject(typeName, objectId);
if (deleted) {
logger.info(`SyncEngine: Removed deleted object ${typeName}/${objectId} from cache`);
}
return false;
}
logger.error(`SyncEngine: Failed to sync object ${objectId}`, error);
return false;
}
}
// ==========================================================================
// Status
// ==========================================================================
/**
* Get current sync engine status
*/
getStatus(): SyncEngineStatus {
const stats = cacheStore.getStats();
let nextIncrementalSync: string | null = null;
if (this.isRunning && this.lastIncrementalSync) {
const nextTime = new Date(this.lastIncrementalSync.getTime() + this.incrementalInterval);
nextIncrementalSync = nextTime.toISOString();
}
return {
isRunning: this.isRunning,
isSyncing: this.isSyncing,
lastFullSync: stats.lastFullSync,
lastIncrementalSync: stats.lastIncrementalSync,
nextIncrementalSync,
incrementalInterval: this.incrementalInterval,
};
}
}
// Export singleton instance
export const syncEngine = new SyncEngine();

View File

@@ -26,6 +26,7 @@ export interface ReferenceValue {
remarks?: string; // Remarks attribute for Governance Model remarks?: string; // Remarks attribute for Governance Model
application?: string; // Application attribute for Governance Model application?: string; // Application attribute for Governance Model
indicators?: string; // Indicators attribute for Business Impact Analyse indicators?: string; // Indicators attribute for Business Impact Analyse
teamType?: string; // Type attribute for Team objects (Business, Enabling, Staf)
} }
// Application list item (summary view) // Application list item (summary view)
@@ -38,7 +39,8 @@ export interface ApplicationListItem {
governanceModel: ReferenceValue | null; governanceModel: ReferenceValue | null;
dynamicsFactor: ReferenceValue | null; dynamicsFactor: ReferenceValue | null;
complexityFactor: ReferenceValue | null; complexityFactor: ReferenceValue | null;
applicationCluster: ReferenceValue | null; applicationSubteam: ReferenceValue | null;
applicationTeam: ReferenceValue | null;
applicationType: ReferenceValue | null; applicationType: ReferenceValue | null;
platform: ReferenceValue | null; // Reference to parent Platform Application Component platform: ReferenceValue | null; // Reference to parent Platform Application Component
requiredEffortApplicationManagement: number | null; // Calculated field requiredEffortApplicationManagement: number | null; // Calculated field
@@ -74,7 +76,8 @@ export interface ApplicationDetails {
complexityFactor: ReferenceValue | null; complexityFactor: ReferenceValue | null;
numberOfUsers: ReferenceValue | null; numberOfUsers: ReferenceValue | null;
governanceModel: ReferenceValue | null; governanceModel: ReferenceValue | null;
applicationCluster: ReferenceValue | null; applicationSubteam: ReferenceValue | null;
applicationTeam: ReferenceValue | null;
applicationType: ReferenceValue | null; applicationType: ReferenceValue | null;
platform: ReferenceValue | null; // Reference to parent Platform Application Component platform: ReferenceValue | null; // Reference to parent Platform Application Component
requiredEffortApplicationManagement: number | null; // Calculated field requiredEffortApplicationManagement: number | null; // Calculated field
@@ -92,7 +95,7 @@ export interface SearchFilters {
governanceModel?: 'all' | 'filled' | 'empty'; governanceModel?: 'all' | 'filled' | 'empty';
dynamicsFactor?: 'all' | 'filled' | 'empty'; dynamicsFactor?: 'all' | 'filled' | 'empty';
complexityFactor?: 'all' | 'filled' | 'empty'; complexityFactor?: 'all' | 'filled' | 'empty';
applicationCluster?: 'all' | 'filled' | 'empty'; applicationSubteam?: 'all' | 'filled' | 'empty' | string; // Can be 'all', 'empty', or a specific subteam name
applicationType?: 'all' | 'filled' | 'empty'; applicationType?: 'all' | 'filled' | 'empty';
organisation?: string; organisation?: string;
hostingType?: string; hostingType?: string;
@@ -168,7 +171,8 @@ export interface PendingChanges {
complexityFactor?: { from: ReferenceValue | null; to: ReferenceValue }; complexityFactor?: { from: ReferenceValue | null; to: ReferenceValue };
numberOfUsers?: { from: ReferenceValue | null; to: ReferenceValue }; numberOfUsers?: { from: ReferenceValue | null; to: ReferenceValue };
governanceModel?: { from: ReferenceValue | null; to: ReferenceValue }; governanceModel?: { from: ReferenceValue | null; to: ReferenceValue };
applicationCluster?: { from: ReferenceValue | null; to: ReferenceValue }; applicationSubteam?: { from: ReferenceValue | null; to: ReferenceValue };
applicationTeam?: { from: ReferenceValue | null; to: ReferenceValue };
applicationType?: { from: ReferenceValue | null; to: ReferenceValue }; applicationType?: { from: ReferenceValue | null; to: ReferenceValue };
} }
@@ -189,7 +193,8 @@ export interface ReferenceOptions {
numberOfUsers: ReferenceValue[]; numberOfUsers: ReferenceValue[];
governanceModels: ReferenceValue[]; governanceModels: ReferenceValue[];
applicationFunctions: ReferenceValue[]; applicationFunctions: ReferenceValue[];
applicationClusters: ReferenceValue[]; applicationSubteams: ReferenceValue[];
applicationTeams: ReferenceValue[];
applicationTypes: ReferenceValue[]; applicationTypes: ReferenceValue[];
organisations: ReferenceValue[]; organisations: ReferenceValue[];
hostingTypes: ReferenceValue[]; hostingTypes: ReferenceValue[];
@@ -297,6 +302,31 @@ export interface PlatformWithWorkloads {
totalEffort: number; // platformEffort + workloadsEffort totalEffort: number; // platformEffort + workloadsEffort
} }
// Subteam level in team dashboard hierarchy
export interface TeamDashboardSubteam {
subteam: ReferenceValue | null;
applications: ApplicationListItem[]; // Regular applications (non-Platform, non-Workload)
platforms: PlatformWithWorkloads[]; // Platforms with their workloads
totalEffort: number; // Sum of all applications + platforms + workloads
minEffort: number; // Sum of all minimum FTE values
maxEffort: number; // Sum of all maximum FTE values
applicationCount: number; // Count of all applications (including platforms and workloads)
byGovernanceModel: Record<string, number>; // Distribution per governance model
}
// Team level in team dashboard hierarchy (contains subteams)
export interface TeamDashboardTeam {
team: ReferenceValue | null; // team.teamType contains "Business", "Enabling", or "Staf"
subteams: TeamDashboardSubteam[];
// Aggregated KPIs (sum of all subteams)
totalEffort: number;
minEffort: number;
maxEffort: number;
applicationCount: number;
byGovernanceModel: Record<string, number>;
}
// Legacy type for backward compatibility (deprecated)
export interface TeamDashboardCluster { export interface TeamDashboardCluster {
cluster: ReferenceValue | null; cluster: ReferenceValue | null;
applications: ApplicationListItem[]; // Regular applications (non-Platform, non-Workload) applications: ApplicationListItem[]; // Regular applications (non-Platform, non-Workload)
@@ -309,16 +339,8 @@ export interface TeamDashboardCluster {
} }
export interface TeamDashboardData { export interface TeamDashboardData {
clusters: TeamDashboardCluster[]; teams: TeamDashboardTeam[];
unassigned: { unassigned: TeamDashboardSubteam; // Apps without team assignment
applications: ApplicationListItem[]; // Regular applications (non-Platform, non-Workload)
platforms: PlatformWithWorkloads[]; // Platforms with their workloads
totalEffort: number; // Sum of all applications + platforms + workloads
minEffort: number; // Sum of all minimum FTE values
maxEffort: number; // Sum of all maximum FTE values
applicationCount: number; // Count of all applications (including platforms and workloads)
byGovernanceModel: Record<string, number>; // Distribution per governance model
};
} }
// Jira Assets API types // Jira Assets API types
@@ -347,6 +369,9 @@ export interface JiraAssetsAttribute {
objectKey: string; objectKey: string;
label: string; label: string;
}; };
status?: {
name: string;
};
}>; }>;
} }
@@ -364,7 +389,8 @@ export interface ApplicationUpdateRequest {
complexityFactor?: string; complexityFactor?: string;
numberOfUsers?: string; numberOfUsers?: string;
governanceModel?: string; governanceModel?: string;
applicationCluster?: string; applicationSubteam?: string;
applicationTeam?: string;
applicationType?: string; applicationType?: string;
hostingType?: string; hostingType?: string;
businessImpactAnalyse?: string; businessImpactAnalyse?: string;

View File

@@ -2,9 +2,9 @@
<html lang="nl"> <html lang="nl">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/logo-zuyderland.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ZiRA Classificatie Tool - Zuyderland</title> <title>CMDB Analyse Tool - Zuyderland</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 600"><defs><style>.cls-1{fill:#7bcaef;}.cls-2{fill:#3b6d89;}</style></defs><g id="Laag_2" data-name="Laag 2"><g id="Logo_beeldmerk_A" data-name="Logo beeldmerk A"><g id="zl-beeldmerk-a"><polygon id="Binnenkant" class="cls-1" points="400 200 400 0 200 0 200 200 0 400 200 400 200 600 400 600 400 400 600 200 400 200"/><g id="Buitenkant"><path class="cls-2" d="M200,200H80A80,80,0,0,1,0,120V80A80,80,0,0,1,80,0H200Z"/><path class="cls-2" d="M600,200H400V0H520a80,80,0,0,1,80,80Z"/><path class="cls-2" d="M200,600H80A80,80,0,0,1,0,520V400H200Z"/><path class="cls-2" d="M520,600H400V400H520a80,80,0,0,1,80,80v40A80,80,0,0,1,520,600Z"/></g></g></g></g></svg>

After

Width:  |  Height:  |  Size: 711 B

View File

@@ -1,14 +1,112 @@
import { useEffect, useState } from 'react'; import { useEffect, useState, useRef } from 'react';
import { Routes, Route, Link, useLocation } from 'react-router-dom'; import { Routes, Route, Link, useLocation, Navigate, useParams } from 'react-router-dom';
import { clsx } from 'clsx'; import { clsx } from 'clsx';
import SearchDashboard from './components/SearchDashboard';
import Dashboard from './components/Dashboard'; import Dashboard from './components/Dashboard';
import ApplicationList from './components/ApplicationList'; import ApplicationList from './components/ApplicationList';
import ApplicationDetail from './components/ApplicationDetail'; import ApplicationInfo from './components/ApplicationInfo';
import GovernanceModelHelper from './components/GovernanceModelHelper';
import TeamDashboard from './components/TeamDashboard'; import TeamDashboard from './components/TeamDashboard';
import ConfigurationV25 from './components/ConfigurationV25'; import ConfigurationV25 from './components/ConfigurationV25';
import ReportsDashboard from './components/ReportsDashboard';
import GovernanceAnalysis from './components/GovernanceAnalysis';
import DataModelDashboard from './components/DataModelDashboard';
import FTECalculator from './components/FTECalculator';
import Login from './components/Login'; import Login from './components/Login';
import { useAuthStore } from './stores/authStore'; import { useAuthStore } from './stores/authStore';
// Redirect component for old app-components/overview/:id paths
function RedirectToApplicationEdit() {
const { id } = useParams<{ id: string }>();
return <Navigate to={`/application/${id}/edit`} replace />;
}
// Dropdown menu item type
interface NavItem {
path: string;
label: string;
exact?: boolean;
}
interface NavDropdown {
label: string;
icon?: React.ReactNode;
items: NavItem[];
basePath: string;
}
// Dropdown component for navigation
function NavDropdown({ dropdown, isActive }: { dropdown: NavDropdown; isActive: boolean }) {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const location = useLocation();
// Close dropdown when clicking outside
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// Close dropdown on route change
useEffect(() => {
setIsOpen(false);
}, [location.pathname]);
return (
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setIsOpen(!isOpen)}
className={clsx(
'flex items-center gap-1 px-3 py-2 rounded-md text-sm font-medium transition-colors',
isActive
? 'bg-blue-50 text-blue-700'
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'
)}
>
{dropdown.label}
<svg
className={clsx('w-4 h-4 transition-transform', isOpen && 'rotate-180')}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{isOpen && (
<div className="absolute left-0 mt-1 w-48 bg-white rounded-lg shadow-lg border border-gray-200 py-1 z-50">
{dropdown.items.map((item) => {
const itemActive = item.exact
? location.pathname === item.path
: location.pathname.startsWith(item.path);
return (
<Link
key={item.path}
to={item.path}
className={clsx(
'block px-4 py-2 text-sm transition-colors',
itemActive
? 'bg-blue-50 text-blue-700'
: 'text-gray-700 hover:bg-gray-50'
)}
>
{item.label}
</Link>
);
})}
</div>
)}
</div>
);
}
function UserMenu() { function UserMenu() {
const { user, authMethod, logout } = useAuthStore(); const { user, authMethod, logout } = useAuthStore();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
@@ -84,12 +182,32 @@ function UserMenu() {
function AppContent() { function AppContent() {
const location = useLocation(); const location = useLocation();
const navItems = [ // Navigation structure
{ path: '/', label: 'Dashboard', exact: true }, const appComponentsDropdown: NavDropdown = {
{ path: '/applications', label: 'Applicaties', exact: false }, label: 'Application Component',
{ path: '/teams', label: 'Team-indeling', exact: true }, basePath: '/application',
{ path: '/configuration', label: 'FTE Config v25', exact: true }, items: [
]; { path: '/app-components', label: 'Dashboard', exact: true },
{ path: '/application/overview', label: 'Overzicht', exact: false },
{ path: '/application/fte-calculator', label: 'FTE Calculator', exact: true },
{ path: '/app-components/fte-config', label: 'FTE Config', exact: true },
],
};
const reportsDropdown: NavDropdown = {
label: 'Rapporten',
basePath: '/reports',
items: [
{ path: '/reports', label: 'Overzicht', exact: true },
{ path: '/reports/team-dashboard', label: 'Team-indeling', exact: true },
{ path: '/reports/governance-analysis', label: 'Analyse Regiemodel', exact: true },
{ path: '/reports/data-model', label: 'Datamodel', exact: true },
],
};
const isAppComponentsActive = location.pathname.startsWith('/app-components') || location.pathname.startsWith('/application');
const isReportsActive = location.pathname.startsWith('/reports');
const isDashboardActive = location.pathname === '/';
return ( return (
<div className="min-h-screen bg-white"> <div className="min-h-screen bg-white">
@@ -98,39 +216,35 @@ function AppContent() {
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16"> <div className="flex justify-between items-center h-16">
<div className="flex items-center space-x-8"> <div className="flex items-center space-x-8">
<div className="flex items-center space-x-3"> <Link to="/" className="flex items-center space-x-3">
<div className="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center"> <img src="/logo-zuyderland.svg" alt="Zuyderland" className="w-9 h-9" />
<span className="text-white font-bold text-sm">ZiRA</span>
</div>
<div> <div>
<h1 className="text-lg font-semibold text-gray-900"> <h1 className="text-lg font-semibold text-gray-900">
Classificatie Tool Analyse Tool
</h1> </h1>
<p className="text-xs text-gray-500">Zuyderland CMDB</p> <p className="text-xs text-gray-500">Zuyderland CMDB</p>
</div> </div>
</div> </Link>
<nav className="hidden md:flex space-x-1"> <nav className="hidden md:flex items-center space-x-1">
{navItems.map((item) => { {/* Dashboard (Search) */}
const isActive = item.exact <Link
? location.pathname === item.path to="/"
: location.pathname.startsWith(item.path); className={clsx(
'px-3 py-2 rounded-md text-sm font-medium transition-colors',
isDashboardActive
? 'bg-blue-50 text-blue-700'
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'
)}
>
Dashboard
</Link>
return ( {/* Application Component Dropdown */}
<Link <NavDropdown dropdown={appComponentsDropdown} isActive={isAppComponentsActive} />
key={item.path}
to={item.path} {/* Reports Dropdown */}
className={clsx( <NavDropdown dropdown={reportsDropdown} isActive={isReportsActive} />
'px-3 py-2 rounded-md text-sm font-medium transition-colors',
isActive
? 'bg-blue-50 text-blue-700'
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'
)}
>
{item.label}
</Link>
);
})}
</nav> </nav>
</div> </div>
@@ -142,9 +256,30 @@ function AppContent() {
{/* Main content */} {/* Main content */}
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<Routes> <Routes>
<Route path="/" element={<Dashboard />} /> {/* Main Dashboard (Search) */}
<Route path="/applications" element={<ApplicationList />} /> <Route path="/" element={<SearchDashboard />} />
<Route path="/applications/:id" element={<ApplicationDetail />} />
{/* Application routes (new structure) */}
<Route path="/application/overview" element={<ApplicationList />} />
<Route path="/application/fte-calculator" element={<FTECalculator />} />
<Route path="/application/:id" element={<ApplicationInfo />} />
<Route path="/application/:id/edit" element={<GovernanceModelHelper />} />
{/* Application Component routes */}
<Route path="/app-components" element={<Dashboard />} />
<Route path="/app-components/fte-config" element={<ConfigurationV25 />} />
{/* Reports routes */}
<Route path="/reports" element={<ReportsDashboard />} />
<Route path="/reports/team-dashboard" element={<TeamDashboard />} />
<Route path="/reports/governance-analysis" element={<GovernanceAnalysis />} />
<Route path="/reports/data-model" element={<DataModelDashboard />} />
{/* Legacy redirects for bookmarks - redirect old paths to new ones */}
<Route path="/app-components/overview" element={<Navigate to="/application/overview" replace />} />
<Route path="/app-components/overview/:id" element={<RedirectToApplicationEdit />} />
<Route path="/applications" element={<Navigate to="/application/overview" replace />} />
<Route path="/applications/:id" element={<RedirectToApplicationEdit />} />
<Route path="/teams" element={<TeamDashboard />} /> <Route path="/teams" element={<TeamDashboard />} />
<Route path="/configuration" element={<ConfigurationV25 />} /> <Route path="/configuration" element={<ConfigurationV25 />} />
</Routes> </Routes>
@@ -178,12 +313,12 @@ function App() {
} }
// Show login if OAuth is enabled and not authenticated // Show login if OAuth is enabled and not authenticated
if (config?.oauthEnabled && !isAuthenticated) { if (config?.authMethod === 'oauth' && !isAuthenticated) {
return <Login />; return <Login />;
} }
// Show login if nothing is configured // Show login if nothing is configured
if (!config?.oauthEnabled && !config?.serviceAccountEnabled) { if (config?.authMethod === 'none') {
return <Login />; return <Login />;
} }

View File

@@ -0,0 +1,620 @@
import { useEffect, useState } from 'react';
import { useParams, Link } from 'react-router-dom';
import { clsx } from 'clsx';
import {
getApplicationById,
getConfig,
getRelatedObjects,
RelatedObject,
} from '../services/api';
import { StatusBadge, BusinessImportanceBadge } from './ApplicationList';
import { EffortDisplay } from './EffortDisplay';
import { useEffortCalculation, getEffectiveFte } from '../hooks/useEffortCalculation';
import type { ApplicationDetails } from '../types';
// Related objects configuration
interface RelatedObjectConfig {
objectType: string;
title: string;
icon: React.ReactNode;
attributes: string[];
columns: { key: string; label: string; isName?: boolean }[];
colorScheme: 'blue' | 'green' | 'orange' | 'purple' | 'cyan';
}
const RELATED_OBJECTS_CONFIG: RelatedObjectConfig[] = [
{
objectType: 'Server',
title: 'Servers',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
</svg>
),
attributes: ['Name', 'Status', 'State'],
columns: [
{ key: 'Name', label: 'Naam', isName: true },
{ key: 'Status', label: 'Status' },
{ key: 'State', label: 'State' },
],
colorScheme: 'blue',
},
{
objectType: 'AzureSubscription',
title: 'Azure Subscriptions',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z" />
</svg>
),
attributes: ['Name', 'Status'],
columns: [
{ key: 'Name', label: 'Naam', isName: true },
{ key: 'Status', label: 'Status' },
],
colorScheme: 'cyan',
},
{
objectType: 'Certificate',
title: 'Certificaten',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
),
attributes: ['Name', 'Status', 'Expiry Date', 'Autorenew', 'Requester', 'Certificate Owner', 'IT Operations Team', 'Application Management'],
columns: [
{ key: 'Name', label: 'Naam', isName: true },
{ key: 'Status', label: 'Status' },
{ key: 'Expiry Date', label: 'Vervaldatum' },
{ key: 'Autorenew', label: 'Auto-renew' },
{ key: 'Certificate Owner', label: 'Eigenaar' },
],
colorScheme: 'orange',
},
{
objectType: 'Connection',
title: 'Connecties',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
),
attributes: ['Name', 'Source', 'Target', 'Type', 'Protocol'],
columns: [
{ key: 'Name', label: 'Naam', isName: true },
{ key: 'Source', label: 'Bron' },
{ key: 'Target', label: 'Doel' },
{ key: 'Type', label: 'Type' },
{ key: 'Protocol', label: 'Protocol' },
],
colorScheme: 'purple',
},
];
const COLOR_SCHEMES = {
blue: {
header: 'bg-blue-50',
icon: 'text-blue-600',
badge: 'bg-blue-100 text-blue-700',
border: 'border-blue-200',
},
green: {
header: 'bg-green-50',
icon: 'text-green-600',
badge: 'bg-green-100 text-green-700',
border: 'border-green-200',
},
orange: {
header: 'bg-orange-50',
icon: 'text-orange-600',
badge: 'bg-orange-100 text-orange-700',
border: 'border-orange-200',
},
purple: {
header: 'bg-purple-50',
icon: 'text-purple-600',
badge: 'bg-purple-100 text-purple-700',
border: 'border-purple-200',
},
cyan: {
header: 'bg-cyan-50',
icon: 'text-cyan-600',
badge: 'bg-cyan-100 text-cyan-700',
border: 'border-cyan-200',
},
};
export default function ApplicationInfo() {
const { id } = useParams<{ id: string }>();
const [application, setApplication] = useState<ApplicationDetails | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [jiraHost, setJiraHost] = useState<string>('');
// Use centralized effort calculation hook
const { calculatedFte, breakdown: effortBreakdown } = useEffortCalculation({
application,
});
// Related objects state
const [relatedObjects, setRelatedObjects] = useState<Map<string, { objects: RelatedObject[]; loading: boolean; error: string | null }>>(new Map());
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set(['Server', 'Certificate'])); // Default expanded
useEffect(() => {
async function fetchData() {
if (!id) return;
setLoading(true);
setError(null);
try {
const [app, config] = await Promise.all([
getApplicationById(id),
getConfig(),
]);
setApplication(app);
setJiraHost(config.jiraHost);
// Note: Effort calculation is handled automatically by useEffortCalculation hook
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load application');
} finally {
setLoading(false);
}
}
fetchData();
}, [id]);
// Set page title
useEffect(() => {
if (application) {
document.title = `${application.name} | Zuyderland CMDB`;
}
return () => {
document.title = 'Zuyderland CMDB';
};
}, [application]);
// Fetch related objects when application is loaded
useEffect(() => {
if (!id || !application) return;
// Initialize loading state for all object types
const initialState = new Map<string, { objects: RelatedObject[]; loading: boolean; error: string | null }>();
RELATED_OBJECTS_CONFIG.forEach(config => {
initialState.set(config.objectType, { objects: [], loading: true, error: null });
});
setRelatedObjects(initialState);
// Fetch each object type in parallel
RELATED_OBJECTS_CONFIG.forEach(async (config) => {
try {
const result = await getRelatedObjects(id, config.objectType, config.attributes);
setRelatedObjects(prev => {
const newMap = new Map(prev);
newMap.set(config.objectType, { objects: result?.objects || [], loading: false, error: null });
return newMap;
});
} catch (err) {
setRelatedObjects(prev => {
const newMap = new Map(prev);
newMap.set(config.objectType, {
objects: [],
loading: false,
error: err instanceof Error ? err.message : 'Failed to load'
});
return newMap;
});
}
});
}, [id, application]);
const toggleSection = (objectType: string) => {
setExpandedSections(prev => {
const newSet = new Set(prev);
if (newSet.has(objectType)) {
newSet.delete(objectType);
} else {
newSet.add(objectType);
}
return newSet;
});
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
</div>
);
}
if (error || !application) {
return (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-red-700">
{error || 'Application not found'}
</div>
);
}
return (
<div className="space-y-6">
{/* Back navigation */}
<div className="flex justify-between items-center">
<Link
to="/application/overview"
className="flex items-center text-gray-600 hover:text-gray-900"
>
<svg
className="w-5 h-5 mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 19l-7-7 7-7"
/>
</svg>
Terug naar overzicht
</Link>
</div>
{/* Header with application name and quick actions */}
<div className="bg-white rounded-lg border border-gray-200 p-6">
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h1 className="text-2xl font-bold text-gray-900">{application.name}</h1>
<StatusBadge status={application.status} />
</div>
<div className="flex flex-wrap items-center gap-3 text-sm text-gray-500">
<span className="font-mono bg-gray-100 px-2 py-0.5 rounded">{application.key}</span>
{application.applicationType && (
<span className="px-2 py-0.5 bg-purple-100 text-purple-700 rounded">
{application.applicationType.name}
</span>
)}
{application.hostingType && (
<span className="px-2 py-0.5 bg-blue-100 text-blue-700 rounded">
{application.hostingType.name}
</span>
)}
</div>
</div>
{/* Quick action buttons */}
<div className="flex flex-wrap gap-2">
{jiraHost && application.key && (
<a
href={`${jiraHost}/secure/insight/assets/${application.key}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors text-sm font-medium"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
Open in Jira
</a>
)}
<Link
to={`/application/${id}/edit`}
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors text-sm font-medium"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
Bewerken
</Link>
</div>
</div>
{/* Description */}
{application.description && (
<div className="mt-4 pt-4 border-t border-gray-100">
<p className="text-gray-600">{application.description}</p>
</div>
)}
</div>
{/* Main info grid */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Left column - Basic info */}
<div className="bg-white rounded-lg border border-gray-200">
<div className="px-6 py-4 border-b border-gray-200 bg-gray-50">
<h3 className="text-lg font-medium text-gray-900">Basis informatie</h3>
</div>
<div className="p-6 space-y-4">
<InfoRow label="Search Reference" value={application.searchReference} />
<InfoRow label="Leverancier/Product" value={application.supplierProduct} />
<InfoRow label="Organisatie" value={application.organisation} />
{application.technischeArchitectuur && application.technischeArchitectuur.trim() !== '' && (
<div>
<label className="block text-sm font-medium text-gray-500 mb-1">Technische Architectuur</label>
<a
href={`${application.technischeArchitectuur}${application.technischeArchitectuur.includes('?') ? '&' : '?'}csf=1&web=1`}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 hover:underline inline-flex items-center gap-1"
>
Document openen
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
</div>
)}
</div>
</div>
{/* Right column - Business info */}
<div className="bg-white rounded-lg border border-gray-200">
<div className="px-6 py-4 border-b border-gray-200 bg-gray-50">
<h3 className="text-lg font-medium text-gray-900">Business informatie</h3>
</div>
<div className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-500 mb-1">Business Importance</label>
<BusinessImportanceBadge importance={application.businessImportance} />
</div>
<InfoRow label="Business Impact Analyse" value={application.businessImpactAnalyse?.name} />
<InfoRow label="Business Owner" value={application.businessOwner} />
<InfoRow label="System Owner" value={application.systemOwner} />
</div>
</div>
</div>
{/* Management section */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Governance */}
<div className="bg-white rounded-lg border border-gray-200">
<div className="px-6 py-4 border-b border-gray-200 bg-gray-50">
<h3 className="text-lg font-medium text-gray-900">Governance & Management</h3>
</div>
<div className="p-6 space-y-4">
<InfoRow label="Regiemodel" value={application.governanceModel?.name} />
<InfoRow label="Subteam" value={application.applicationSubteam?.name} />
<InfoRow label="Team" value={application.applicationTeam?.name} />
<InfoRow label="Application Management - Hosting" value={application.applicationManagementHosting?.name} />
<InfoRow label="Application Management - TAM" value={application.applicationManagementTAM?.name} />
</div>
</div>
{/* Contacts */}
<div className="bg-white rounded-lg border border-gray-200">
<div className="px-6 py-4 border-b border-gray-200 bg-gray-50">
<h3 className="text-lg font-medium text-gray-900">Contactpersonen</h3>
</div>
<div className="p-6 space-y-4">
<InfoRow label="Functioneel Beheer" value={application.functionalApplicationManagement} />
<InfoRow label="Technisch Applicatiebeheer" value={application.technicalApplicationManagement} />
<InfoRow
label="Contactpersonen TAB"
value={(() => {
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(', ') : undefined;
})()}
/>
</div>
</div>
</div>
{/* Classification section */}
<div className="bg-white rounded-lg border border-gray-200">
<div className="px-6 py-4 border-b border-gray-200 bg-gray-50">
<h3 className="text-lg font-medium text-gray-900">Classificatie</h3>
</div>
<div className="p-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<InfoRow label="Dynamics Factor" value={application.dynamicsFactor?.name} />
<InfoRow label="Complexity Factor" value={application.complexityFactor?.name} />
<InfoRow label="Number of Users" value={application.numberOfUsers?.name} />
</div>
{/* FTE - Benodigde inspanning applicatiemanagement */}
<div className="mt-6 pt-6 border-t border-gray-100">
<label className="block text-sm font-medium text-gray-700 mb-3">
Benodigde inspanning applicatiemanagement
</label>
<div className="w-full border border-gray-300 rounded-lg px-3 py-2 bg-gray-50">
<EffortDisplay
effectiveFte={getEffectiveFte(calculatedFte, application.overrideFTE, application.requiredEffortApplicationManagement)}
calculatedFte={calculatedFte ?? application.requiredEffortApplicationManagement ?? null}
overrideFte={application.overrideFTE ?? null}
breakdown={effortBreakdown}
isPreview={false}
showDetails={true}
showOverrideInput={false}
/>
</div>
<p className="mt-1 text-xs text-gray-500">
Automatisch berekend op basis van Regiemodel, Application Type, Business Impact Analyse en Hosting (v25)
</p>
</div>
</div>
</div>
{/* Application Functions */}
{application.applicationFunctions && application.applicationFunctions.length > 0 && (
<div className="bg-white rounded-lg border border-gray-200">
<div className="px-6 py-4 border-b border-gray-200 bg-gray-50">
<h3 className="text-lg font-medium text-gray-900">
Applicatiefuncties ({application.applicationFunctions.length})
</h3>
</div>
<div className="p-6">
<div className="flex flex-wrap gap-2">
{application.applicationFunctions.map((func, index) => (
<span
key={func.objectId || index}
className={clsx(
'inline-flex items-center px-3 py-1 rounded-full text-sm',
index === 0
? 'bg-blue-100 text-blue-800 font-medium'
: 'bg-gray-100 text-gray-700'
)}
>
<span className="font-mono text-xs mr-2 opacity-70">{func.key}</span>
{func.name}
</span>
))}
</div>
</div>
</div>
)}
{/* Related Objects Sections */}
<div className="space-y-4">
<h2 className="text-lg font-semibold text-gray-900">Gerelateerde objecten</h2>
{RELATED_OBJECTS_CONFIG.map((config) => {
const data = relatedObjects.get(config.objectType);
const isExpanded = expandedSections.has(config.objectType);
const colors = COLOR_SCHEMES[config.colorScheme];
const objects = data?.objects || [];
const count = objects.length;
const isLoading = data?.loading ?? true;
return (
<div key={config.objectType} className={clsx('bg-white rounded-lg border', colors.border)}>
{/* Header - clickable to expand/collapse */}
<button
onClick={() => toggleSection(config.objectType)}
className={clsx(
'w-full px-6 py-4 flex items-center justify-between',
colors.header,
'hover:opacity-90 transition-opacity'
)}
>
<div className="flex items-center gap-3">
<span className={colors.icon}>{config.icon}</span>
<h3 className="text-lg font-medium text-gray-900">{config.title}</h3>
{isLoading ? (
<div className="animate-spin rounded-full h-4 w-4 border-2 border-gray-300 border-t-gray-600" />
) : (
<span className={clsx('px-2 py-0.5 rounded-full text-xs font-medium', colors.badge)}>
{count}
</span>
)}
</div>
<svg
className={clsx(
'w-5 h-5 text-gray-500 transition-transform',
isExpanded && 'transform rotate-180'
)}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{/* Content */}
{isExpanded && (
<div className="border-t border-gray-100">
{isLoading ? (
<div className="p-6 text-center">
<div className="animate-spin rounded-full h-6 w-6 border-2 border-gray-300 border-t-blue-600 mx-auto" />
<p className="text-gray-500 text-sm mt-2">Laden...</p>
</div>
) : data?.error ? (
<div className="p-6 text-center text-red-600">
<p className="text-sm">{data.error}</p>
</div>
) : count === 0 ? (
<div className="p-6 text-center text-gray-500">
<p className="text-sm">Geen {config.title.toLowerCase()} gevonden</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
{config.columns.map((col) => (
<th
key={col.key}
className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
{col.label}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{objects.map((obj) => (
<tr key={obj.id} className="hover:bg-gray-50">
{config.columns.map((col) => (
<td key={col.key} className="px-4 py-3 text-sm text-gray-900">
{col.isName && jiraHost ? (
<a
href={`${jiraHost}/secure/insight/assets/${obj.key}`}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 hover:underline inline-flex items-center gap-1"
>
{obj.attributes[col.key] || obj.name || '-'}
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
) : (
obj.attributes[col.key] || '-'
)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</div>
);
})}
</div>
{/* Call to action */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h3 className="text-lg font-medium text-blue-900">Classificatie aanpassen?</h3>
<p className="text-blue-700 text-sm mt-1">
Bewerk applicatiefuncties, classificatie en regiemodel met AI-ondersteuning.
</p>
</div>
<Link
to={`/application/${id}/edit`}
className="inline-flex items-center gap-2 px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors font-medium whitespace-nowrap"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
Bewerken
</Link>
</div>
</div>
</div>
);
}
// Helper component for displaying info rows
function InfoRow({ label, value }: { label: string; value?: string | null }) {
return (
<div>
<label className="block text-sm font-medium text-gray-500 mb-1">{label}</label>
<p className="text-gray-900">{value || '-'}</p>
</div>
);
}

View File

@@ -29,7 +29,7 @@ export default function ApplicationList() {
setStatuses, setStatuses,
setApplicationFunction, setApplicationFunction,
setGovernanceModel, setGovernanceModel,
setApplicationCluster, setApplicationSubteam,
setApplicationType, setApplicationType,
setOrganisation, setOrganisation,
setHostingType, setHostingType,
@@ -45,6 +45,7 @@ export default function ApplicationList() {
const [organisations, setOrganisations] = useState<ReferenceValue[]>([]); const [organisations, setOrganisations] = useState<ReferenceValue[]>([]);
const [hostingTypes, setHostingTypes] = useState<ReferenceValue[]>([]); const [hostingTypes, setHostingTypes] = useState<ReferenceValue[]>([]);
const [businessImportanceOptions, setBusinessImportanceOptions] = useState<ReferenceValue[]>([]); const [businessImportanceOptions, setBusinessImportanceOptions] = useState<ReferenceValue[]>([]);
const [applicationSubteams, setApplicationSubteams] = useState<ReferenceValue[]>([]);
const [showFilters, setShowFilters] = useState(true); const [showFilters, setShowFilters] = useState(true);
// Sync URL params with store on mount // Sync URL params with store on mount
@@ -98,6 +99,7 @@ export default function ApplicationList() {
setOrganisations(data.organisations); setOrganisations(data.organisations);
setHostingTypes(data.hostingTypes); setHostingTypes(data.hostingTypes);
setBusinessImportanceOptions(data.businessImportance || []); setBusinessImportanceOptions(data.businessImportance || []);
setApplicationSubteams(data.applicationSubteams || []);
} catch (err) { } catch (err) {
console.error('Failed to load reference data', err); console.error('Failed to load reference data', err);
} }
@@ -126,7 +128,7 @@ export default function ApplicationList() {
// Only navigate programmatically for regular clicks // Only navigate programmatically for regular clicks
if (!event.ctrlKey && !event.metaKey && !event.shiftKey && event.button === 0) { if (!event.ctrlKey && !event.metaKey && !event.shiftKey && event.button === 0) {
event.preventDefault(); event.preventDefault();
navigate(`/applications/${app.id}`); navigate(`/application/${app.id}`);
} }
}; };
@@ -257,26 +259,6 @@ export default function ApplicationList() {
</div> </div>
</div> </div>
<div>
<label className="label mb-2">Application Cluster</label>
<div className="space-y-1">
{(['all', 'filled', 'empty'] as const).map((value) => (
<label key={value} className="flex items-center space-x-2">
<input
type="radio"
name="applicationCluster"
checked={filters.applicationCluster === value}
onChange={() => setApplicationCluster(value)}
className="border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700">
{value === 'all' ? 'Alle' : value === 'filled' ? 'Ingevuld' : 'Leeg'}
</span>
</label>
))}
</div>
</div>
<div> <div>
<label className="label mb-2">Application Type</label> <label className="label mb-2">Application Type</label>
<div className="space-y-1"> <div className="space-y-1">
@@ -347,6 +329,23 @@ export default function ApplicationList() {
))} ))}
</select> </select>
</div> </div>
<div>
<label className="label mb-2">Subteam</label>
<select
value={filters.applicationSubteam || 'all'}
onChange={(e) => setApplicationSubteam(e.target.value as 'all' | 'empty' | string)}
className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="all">Alle</option>
<option value="empty">Leeg</option>
{applicationSubteams.map((subteam) => (
<option key={subteam.objectId} value={subteam.name}>
{subteam.name}
</option>
))}
</select>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -405,7 +404,7 @@ export default function ApplicationList() {
> >
<td className="py-0"> <td className="py-0">
<Link <Link
to={`/applications/${app.id}`} to={`/application/${app.id}`}
onClick={(e) => handleRowClick(app, index, e)} onClick={(e) => handleRowClick(app, index, e)}
className="block px-4 py-3 text-sm text-gray-500" className="block px-4 py-3 text-sm text-gray-500"
> >
@@ -414,7 +413,7 @@ export default function ApplicationList() {
</td> </td>
<td className="py-0"> <td className="py-0">
<Link <Link
to={`/applications/${app.id}`} to={`/application/${app.id}`}
onClick={(e) => handleRowClick(app, index, e)} onClick={(e) => handleRowClick(app, index, e)}
className="block px-4 py-3" className="block px-4 py-3"
> >
@@ -426,7 +425,7 @@ export default function ApplicationList() {
</td> </td>
<td className="py-0"> <td className="py-0">
<Link <Link
to={`/applications/${app.id}`} to={`/application/${app.id}`}
onClick={(e) => handleRowClick(app, index, e)} onClick={(e) => handleRowClick(app, index, e)}
className="block px-4 py-3" className="block px-4 py-3"
> >
@@ -435,7 +434,7 @@ export default function ApplicationList() {
</td> </td>
<td className="py-0"> <td className="py-0">
<Link <Link
to={`/applications/${app.id}`} to={`/application/${app.id}`}
onClick={(e) => handleRowClick(app, index, e)} onClick={(e) => handleRowClick(app, index, e)}
className="block px-4 py-3" className="block px-4 py-3"
> >
@@ -460,7 +459,7 @@ export default function ApplicationList() {
</td> </td>
<td className="py-0"> <td className="py-0">
<Link <Link
to={`/applications/${app.id}`} to={`/application/${app.id}`}
onClick={(e) => handleRowClick(app, index, e)} onClick={(e) => handleRowClick(app, index, e)}
className="block px-4 py-3" className="block px-4 py-3"
> >
@@ -477,7 +476,7 @@ export default function ApplicationList() {
</td> </td>
<td className="py-0"> <td className="py-0">
<Link <Link
to={`/applications/${app.id}`} to={`/application/${app.id}`}
onClick={(e) => handleRowClick(app, index, e)} onClick={(e) => handleRowClick(app, index, e)}
className="block px-4 py-3 text-sm text-gray-900" className="block px-4 py-3 text-sm text-gray-900"
> >
@@ -502,7 +501,7 @@ export default function ApplicationList() {
<div className="px-4 py-3 bg-gray-50 border-t border-gray-200 flex items-center justify-between"> <div className="px-4 py-3 bg-gray-50 border-t border-gray-200 flex items-center justify-between">
{currentPage > 1 ? ( {currentPage > 1 ? (
<Link <Link
to={currentPage === 2 ? '/applications' : `/applications?page=${currentPage - 1}`} to={currentPage === 2 ? '/application/overview' : `/application/overview?page=${currentPage - 1}`}
onClick={() => setCurrentPage(currentPage - 1)} onClick={() => setCurrentPage(currentPage - 1)}
className="btn btn-secondary" className="btn btn-secondary"
> >
@@ -518,7 +517,7 @@ export default function ApplicationList() {
</span> </span>
{currentPage < result.totalPages ? ( {currentPage < result.totalPages ? (
<Link <Link
to={`/applications?page=${currentPage + 1}`} to={`/application/overview?page=${currentPage + 1}`}
onClick={() => setCurrentPage(currentPage + 1)} onClick={() => setCurrentPage(currentPage + 1)}
className="btn btn-secondary" className="btn btn-secondary"
> >

View File

@@ -0,0 +1,180 @@
import React, { useState, useEffect } from 'react';
import { getCacheStatus, triggerSync, type CacheStatus } from '../services/api';
interface CacheStatusIndicatorProps {
/** Show compact version (just icon + time) */
compact?: boolean;
/** Auto-refresh interval in ms (default: 30000) */
refreshInterval?: number;
}
export const CacheStatusIndicator: React.FC<CacheStatusIndicatorProps> = ({
compact = false,
refreshInterval = 30000,
}) => {
const [status, setStatus] = useState<CacheStatus | null>(null);
const [loading, setLoading] = useState(true);
const [syncing, setSyncing] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchStatus = async () => {
try {
const data = await getCacheStatus();
setStatus(data);
setError(null);
} catch (err) {
setError('Kon status niet ophalen');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchStatus();
const interval = setInterval(fetchStatus, refreshInterval);
return () => clearInterval(interval);
}, [refreshInterval]);
const handleSync = async () => {
if (syncing) return;
setSyncing(true);
try {
await triggerSync();
// Refetch status after a short delay
setTimeout(fetchStatus, 1000);
} catch (err) {
setError('Sync mislukt');
} finally {
setSyncing(false);
}
};
if (loading) {
return (
<div className={`inline-flex items-center gap-2 ${compact ? 'text-xs' : 'text-sm'} text-zinc-500`}>
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
{!compact && <span>Laden...</span>}
</div>
);
}
if (error || !status) {
return (
<div className={`inline-flex items-center gap-2 ${compact ? 'text-xs' : 'text-sm'} text-red-400`}>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{!compact && <span>{error || 'Geen data'}</span>}
</div>
);
}
const lastSync = status.cache.lastIncrementalSync;
const ageMinutes = lastSync
? Math.floor((Date.now() - new Date(lastSync).getTime()) / 60000)
: null;
const isWarm = status.cache.isWarm;
const isSyncing = status.sync.isSyncing || syncing;
// Status color
const statusColor = !isWarm
? 'text-amber-400'
: ageMinutes !== null && ageMinutes > 5
? 'text-amber-400'
: 'text-emerald-400';
if (compact) {
return (
<button
onClick={handleSync}
disabled={isSyncing}
className={`inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs ${statusColor} hover:bg-zinc-800 transition-colors disabled:opacity-50`}
title={`Cache: ${status.cache.totalObjects} objecten, laatst gesynchroniseerd ${ageMinutes !== null ? `${ageMinutes} min geleden` : 'onbekend'}`}
>
{isSyncing ? (
<svg className="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
) : (
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
)}
<span>{ageMinutes !== null ? `${ageMinutes}m` : '?'}</span>
</button>
);
}
return (
<div className="bg-zinc-800/50 rounded-lg border border-zinc-700 p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium text-zinc-300 flex items-center gap-2">
<svg className="h-4 w-4 text-zinc-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4" />
</svg>
Cache Status
</h3>
<span className={`text-xs px-2 py-0.5 rounded-full ${isWarm ? 'bg-emerald-500/20 text-emerald-400' : 'bg-amber-500/20 text-amber-400'}`}>
{isWarm ? 'Actief' : 'Cold Start'}
</span>
</div>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-zinc-500">Objecten in cache:</span>
<span className="text-zinc-200 font-mono">{status.cache.totalObjects.toLocaleString()}</span>
</div>
<div className="flex justify-between">
<span className="text-zinc-500">Relaties:</span>
<span className="text-zinc-200 font-mono">{status.cache.totalRelations.toLocaleString()}</span>
</div>
<div className="flex justify-between">
<span className="text-zinc-500">Laatst gesynchroniseerd:</span>
<span className={`font-mono ${statusColor}`}>
{ageMinutes !== null ? `${ageMinutes} min geleden` : 'Nooit'}
</span>
</div>
{status.sync.isSyncing && (
<div className="flex items-center gap-2 text-blue-400">
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
<span>Synchronisatie bezig...</span>
</div>
)}
</div>
<button
onClick={handleSync}
disabled={isSyncing}
className="mt-4 w-full flex items-center justify-center gap-2 px-3 py-2 rounded-lg bg-zinc-700 text-zinc-200 hover:bg-zinc-600 transition-colors disabled:opacity-50 text-sm font-medium"
>
{isSyncing ? (
<>
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
Synchroniseren...
</>
) : (
<>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Nu synchroniseren
</>
)}
</button>
</div>
);
};
export default CacheStatusIndicator;

View File

@@ -0,0 +1,162 @@
import React from 'react';
import type { ConflictError } from '../services/api';
interface ConflictDialogProps {
conflict: ConflictError;
onForceOverwrite: () => void;
onDiscard: () => void;
onClose: () => void;
isLoading?: boolean;
}
export const ConflictDialog: React.FC<ConflictDialogProps> = ({
conflict,
onForceOverwrite,
onDiscard,
onClose,
isLoading = false,
}) => {
return (
<div className="fixed inset-0 z-50 overflow-y-auto">
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/60 transition-opacity"
onClick={onClose}
/>
{/* Dialog */}
<div className="flex min-h-full items-center justify-center p-4">
<div className="relative bg-zinc-900 rounded-xl shadow-2xl border border-zinc-700 max-w-2xl w-full overflow-hidden">
{/* Header */}
<div className="px-6 py-4 border-b border-zinc-700 bg-amber-950/50">
<div className="flex items-center gap-3">
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-amber-500/20 flex items-center justify-center">
<svg className="w-5 h-5 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<div>
<h2 className="text-lg font-semibold text-amber-400">
Wijzigingsconflict Gedetecteerd
</h2>
<p className="text-sm text-zinc-400">
{conflict.message}
</p>
</div>
</div>
</div>
{/* Content */}
<div className="px-6 py-5">
{conflict.warning && (
<p className="text-sm text-zinc-400 mb-4">{conflict.warning}</p>
)}
{conflict.conflicts && conflict.conflicts.length > 0 && (
<>
<p className="text-sm text-zinc-300 mb-4">
De volgende velden zijn gewijzigd terwijl u aan het bewerken was:
</p>
<div className="rounded-lg overflow-hidden border border-zinc-700">
<table className="w-full text-sm">
<thead>
<tr className="bg-zinc-800">
<th className="px-4 py-2.5 text-left font-medium text-zinc-300">Veld</th>
<th className="px-4 py-2.5 text-left font-medium text-zinc-300">Uw waarde</th>
<th className="px-4 py-2.5 text-left font-medium text-zinc-300">Waarde in Jira</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-700">
{conflict.conflicts.map((c, index) => (
<tr key={index} className="bg-zinc-800/50">
<td className="px-4 py-3 font-medium text-zinc-200">
{c.field}
</td>
<td className="px-4 py-3">
<span className="inline-flex items-center px-2 py-1 rounded bg-blue-500/20 text-blue-400 text-xs font-mono">
{formatValue(c.proposedValue)}
</span>
</td>
<td className="px-4 py-3">
<span className="inline-flex items-center px-2 py-1 rounded bg-amber-500/20 text-amber-400 text-xs font-mono">
{formatValue(c.jiraValue)}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
{/* Info box */}
<div className="mt-5 p-4 rounded-lg bg-zinc-800/50 border border-zinc-700">
<p className="text-sm text-zinc-400">
<span className="font-medium text-zinc-300">Wat wilt u doen?</span>
<br />
<strong>Doorvoeren:</strong> Uw wijzigingen overschrijven de huidige waarden in Jira
<br />
<strong>Verwerpen:</strong> Uw wijzigingen worden weggegooid en de huidige data wordt geladen
</p>
</div>
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-zinc-700 bg-zinc-800/50 flex justify-end gap-3">
<button
onClick={onDiscard}
disabled={isLoading}
className="px-4 py-2 rounded-lg bg-zinc-700 text-zinc-200 hover:bg-zinc-600 transition-colors disabled:opacity-50 font-medium"
>
Verwerpen en verversen
</button>
<button
onClick={onForceOverwrite}
disabled={isLoading}
className="px-4 py-2 rounded-lg bg-amber-600 text-white hover:bg-amber-500 transition-colors disabled:opacity-50 font-medium flex items-center gap-2"
>
{isLoading && (
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
)}
Mijn wijzigingen doorvoeren
</button>
</div>
</div>
</div>
</div>
);
};
function formatValue(value: unknown): string {
if (value === null || value === undefined) {
return '(leeg)';
}
if (Array.isArray(value)) {
if (value.length === 0) return '(leeg)';
return value.map(v => {
if (typeof v === 'object' && v && 'label' in v) {
return (v as { label: string }).label;
}
return String(v);
}).join(', ');
}
if (typeof value === 'object' && value && 'label' in value) {
return (value as { label: string }).label;
}
if (typeof value === 'boolean') {
return value ? 'Ja' : 'Nee';
}
return String(value);
}
export default ConflictDialog;

View File

@@ -14,19 +14,32 @@ interface CustomSelectProps {
// Helper function to get display text for an option // Helper function to get display text for an option
function getDisplayText(option: ReferenceValue, showSummary: boolean, showRemarks: boolean): string | null { function getDisplayText(option: ReferenceValue, showSummary: boolean, showRemarks: boolean): string | null {
if (showRemarks) { if (showRemarks) {
// Concatenate description and remarks with ". " // Concatenate description, remarks, and indicators with ". "
const parts: string[] = []; const parts: string[] = [];
if (option.description) parts.push(option.description); if (option.description) parts.push(option.description);
if (option.remarks) parts.push(option.remarks); if (option.remarks) parts.push(option.remarks);
if (option.indicators) parts.push(option.indicators);
return parts.length > 0 ? parts.join('. ') : null; return parts.length > 0 ? parts.join('. ') : null;
} }
if (showSummary && option.summary) { if (showSummary && option.summary) {
// Include indicators if available
if (option.indicators) {
return `${option.summary}. ${option.indicators}`;
}
return option.summary; return option.summary;
} }
if (showSummary && !option.summary && option.description) { if (showSummary && !option.summary && option.description) {
// Include indicators if available
if (option.indicators) {
return `${option.description}. ${option.indicators}`;
}
return option.description; return option.description;
} }
if (!showSummary && option.description) { if (!showSummary && option.description) {
// Include indicators if available
if (option.indicators) {
return `${option.description}. ${option.indicators}`;
}
return option.description; return option.description;
} }
return null; return null;

View File

@@ -1,7 +1,7 @@
import { useEffect, useState, useCallback } from 'react'; import { useEffect, useState, useCallback, useRef } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { getDashboardStats, getRecentClassifications } from '../services/api'; import { getDashboardStats, getRecentClassifications, getReferenceData } from '../services/api';
import type { DashboardStats, ClassificationResult } from '../types'; import type { DashboardStats, ClassificationResult, ReferenceValue } from '../types';
// Extended type to include stale indicator from API // Extended type to include stale indicator from API
interface DashboardStatsWithMeta extends DashboardStats { interface DashboardStatsWithMeta extends DashboardStats {
@@ -12,9 +12,36 @@ interface DashboardStatsWithMeta extends DashboardStats {
export default function Dashboard() { export default function Dashboard() {
const [stats, setStats] = useState<DashboardStatsWithMeta | null>(null); const [stats, setStats] = useState<DashboardStatsWithMeta | null>(null);
const [recentClassifications, setRecentClassifications] = useState<ClassificationResult[]>([]); const [recentClassifications, setRecentClassifications] = useState<ClassificationResult[]>([]);
const [governanceModels, setGovernanceModels] = useState<ReferenceValue[]>([]);
const [hoveredGovModel, setHoveredGovModel] = useState<string | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const hoverTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Hover handlers with delayed hide to prevent flickering when moving between badges
const handleGovModelMouseEnter = useCallback((hoverKey: string) => {
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
hoverTimeoutRef.current = null;
}
setHoveredGovModel(hoverKey);
}, []);
const handleGovModelMouseLeave = useCallback(() => {
hoverTimeoutRef.current = setTimeout(() => {
setHoveredGovModel(null);
}, 100); // Small delay to allow moving to another badge
}, []);
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
}
};
}, []);
const fetchData = useCallback(async (forceRefresh: boolean = false) => { const fetchData = useCallback(async (forceRefresh: boolean = false) => {
if (forceRefresh) { if (forceRefresh) {
@@ -25,12 +52,14 @@ export default function Dashboard() {
setError(null); setError(null);
try { try {
const [statsData, recentData] = await Promise.all([ const [statsData, recentData, refData] = await Promise.all([
getDashboardStats(forceRefresh), getDashboardStats(forceRefresh),
getRecentClassifications(10), getRecentClassifications(10),
getReferenceData(),
]); ]);
setStats(statsData as DashboardStatsWithMeta); setStats(statsData as DashboardStatsWithMeta);
setRecentClassifications(recentData); setRecentClassifications(recentData);
setGovernanceModels(refData.governanceModels);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load dashboard'); setError(err instanceof Error ? err.message : 'Failed to load dashboard');
} finally { } finally {
@@ -104,7 +133,7 @@ export default function Dashboard() {
</svg> </svg>
<span>{refreshing ? 'Laden...' : 'Ververs'}</span> <span>{refreshing ? 'Laden...' : 'Ververs'}</span>
</button> </button>
<Link to="/applications" className="btn btn-primary"> <Link to="/app-components/overview" className="btn btn-primary">
Start classificeren Start classificeren
</Link> </Link>
</div> </div>
@@ -141,23 +170,42 @@ export default function Dashboard() {
</div> </div>
</div> </div>
{/* Progress bar */} {/* Progress bars */}
<div className="card p-6"> <div className="card p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4"> <h3 className="text-lg font-medium text-gray-900 mb-4">
Classificatie voortgang Classificatie voortgang
</h3> </h3>
<div className="space-y-2"> <div className="space-y-4">
<div className="flex justify-between text-sm text-gray-600"> {/* ICT Governance Model Progress */}
<span>ApplicationFunction ingevuld</span> <div className="space-y-2">
<span> <div className="flex justify-between text-sm text-gray-600">
{stats?.classifiedCount || 0} / {stats?.totalApplications || 0} <span>ICT Governance Model ingevuld</span>
</span> <span>
{stats?.classifiedCount || 0} / {stats?.totalApplications || 0} ({progressPercentage}%)
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-4">
<div
className="bg-blue-600 h-4 rounded-full transition-all duration-500"
style={{ width: `${progressPercentage}%` }}
/>
</div>
</div> </div>
<div className="w-full bg-gray-200 rounded-full h-4">
<div {/* ApplicationFunction Progress */}
className="bg-blue-600 h-4 rounded-full transition-all duration-500" <div className="space-y-2">
style={{ width: `${progressPercentage}%` }} <div className="flex justify-between text-sm text-gray-600">
/> <span>ApplicationFunction ingevuld</span>
<span>
{stats?.withApplicationFunction || 0} / {stats?.totalApplications || 0} ({stats?.applicationFunctionPercentage || 0}%)
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-4">
<div
className="bg-green-600 h-4 rounded-full transition-all duration-500"
style={{ width: `${stats?.applicationFunctionPercentage || 0}%` }}
/>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -186,7 +234,7 @@ export default function Dashboard() {
<div <div
className="bg-blue-600 h-2 rounded-full" className="bg-blue-600 h-2 rounded-full"
style={{ style={{
width: `${(count / (stats?.totalApplications || 1)) * 100}%`, width: `${(count / (stats?.totalAllApplications || 1)) * 100}%`,
}} }}
/> />
</div> </div>
@@ -200,37 +248,110 @@ export default function Dashboard() {
</div> </div>
{/* Governance model distribution */} {/* Governance model distribution */}
<div className="card p-6"> <div className="card p-6" style={{ overflow: 'visible', position: 'relative', zIndex: hoveredGovModel ? 100 : 1 }}>
<h3 className="text-lg font-medium text-gray-900 mb-4"> <h3 className="text-lg font-medium text-gray-900 mb-4 flex items-center gap-2">
Verdeling per regiemodel Verdeling per regiemodel
<span className="text-gray-400 text-xs font-normal" title="Hover voor details"></span>
</h3> </h3>
<div className="space-y-3"> <div className="flex flex-wrap gap-2" style={{ overflow: 'visible' }}>
{stats?.byGovernanceModel && {stats?.byGovernanceModel &&
Object.entries(stats.byGovernanceModel) [
.sort((a, b) => { ...governanceModels
// Sort alphabetically, but put "Niet ingesteld" at the end .map(g => g.name)
if (a[0] === 'Niet ingesteld') return 1; .sort((a, b) => a.localeCompare(b, 'nl', { sensitivity: 'base' })),
if (b[0] === 'Niet ingesteld') return -1; 'Niet ingesteld'
return a[0].localeCompare(b[0], 'nl', { sensitivity: 'base' }); ]
}) .filter(govModel => stats.byGovernanceModel[govModel] !== undefined || govModel === 'Niet ingesteld')
.map(([model, count]) => ( .map((govModel) => {
<div key={model} className="flex items-center justify-between"> const count = stats.byGovernanceModel[govModel] || 0;
<span className="text-sm text-gray-600">{model}</span> const colors = (() => {
<div className="flex items-center space-x-2"> if (govModel.includes('Regiemodel A')) return { bg: '#20556B', text: '#FFFFFF' };
<div className="w-32 bg-gray-200 rounded-full h-2"> if (govModel.includes('Regiemodel B+') || govModel.includes('B+')) return { bg: '#286B86', text: '#FFFFFF' };
<div if (govModel.includes('Regiemodel B')) return { bg: '#286B86', text: '#FFFFFF' };
className="bg-purple-600 h-2 rounded-full" if (govModel.includes('Regiemodel C')) return { bg: '#81CBF2', text: '#20556B' };
style={{ if (govModel.includes('Regiemodel D')) return { bg: '#F5A733', text: '#FFFFFF' };
width: `${(count / (stats?.totalApplications || 1)) * 100}%`, if (govModel.includes('Regiemodel E')) return { bg: '#E95053', text: '#FFFFFF' };
}} if (govModel === 'Niet ingesteld') return { bg: '#E5E7EB', text: '#9CA3AF' };
/> return { bg: '#6B7280', text: '#FFFFFF' };
})();
const shortLabel = govModel === 'Niet ingesteld'
? '?'
: (govModel.match(/Regiemodel\s+(.+)/i)?.[1] || govModel.charAt(0));
const govModelData = governanceModels.find(g => g.name === govModel);
const isHovered = hoveredGovModel === govModel;
return (
<div
key={govModel}
className="rounded-xl py-2 shadow-sm hover:shadow-lg transition-all duration-200 w-[48px] text-center cursor-pointer"
style={{
backgroundColor: colors.bg,
color: colors.text,
position: 'relative'
}}
onMouseEnter={() => handleGovModelMouseEnter(govModel)}
onMouseLeave={handleGovModelMouseLeave}
>
<div className="text-[10px] font-bold uppercase tracking-wider" style={{ opacity: 0.9 }}>
{shortLabel}
</div> </div>
<span className="text-sm font-medium text-gray-900 w-8 text-right"> <div className="text-xl font-bold leading-tight">
{count} {count}
</span> </div>
{/* Hover popup */}
{isHovered && govModel !== 'Niet ingesteld' && (
<div
className="absolute left-0 top-full mt-3 w-80 rounded-xl shadow-2xl border border-gray-200 p-4 text-left z-50"
style={{
pointerEvents: 'auto',
backgroundColor: '#ffffff'
}}
>
{/* Arrow pointer */}
<div
className="absolute -top-2 left-5 w-0 h-0 border-l-8 border-r-8 border-b-8 border-transparent"
style={{ borderBottomColor: '#ffffff', filter: 'drop-shadow(0 -1px 1px rgba(0,0,0,0.1))' }}
/>
{/* Header: Summary (Description) */}
<div className="text-sm font-bold text-gray-900 mb-2">
{govModelData?.summary || govModel}
{govModelData?.description && (
<span className="font-normal text-gray-500"> ({govModelData.description})</span>
)}
</div>
{/* Remarks */}
{govModelData?.remarks && (
<div className="text-xs text-gray-600 mb-3 whitespace-pre-wrap leading-relaxed">
{govModelData.remarks}
</div>
)}
{/* Application section */}
{govModelData?.application && (
<div className="border-t border-gray-100 pt-3 mt-3">
<div className="text-xs font-bold text-gray-700 mb-1.5 uppercase tracking-wide">
Toepassing
</div>
<div className="text-xs text-gray-600 whitespace-pre-wrap leading-relaxed">
{govModelData.application}
</div>
</div>
)}
{/* Fallback message if no data */}
{!govModelData && (
<div className="text-xs text-gray-400 italic">
Geen aanvullende informatie beschikbaar
</div>
)}
</div>
)}
</div> </div>
</div> );
))} })}
{(!stats?.byGovernanceModel || {(!stats?.byGovernanceModel ||
Object.keys(stats.byGovernanceModel).length === 0) && ( Object.keys(stats.byGovernanceModel).length === 0) && (
<p className="text-sm text-gray-500">Geen data beschikbaar</p> <p className="text-sm text-gray-500">Geen data beschikbaar</p>

View File

@@ -0,0 +1,648 @@
import { useState, useEffect, useMemo, useCallback } from 'react';
import { getSchema, triggerTypeSync, type SchemaResponse, type SchemaObjectTypeDefinition, type SchemaAttributeDefinition } from '../services/api';
// Attribute type badge colors
const typeColors: Record<string, { bg: string; text: string }> = {
text: { bg: 'bg-gray-100', text: 'text-gray-700' },
integer: { bg: 'bg-blue-100', text: 'text-blue-700' },
float: { bg: 'bg-blue-100', text: 'text-blue-700' },
boolean: { bg: 'bg-purple-100', text: 'text-purple-700' },
date: { bg: 'bg-yellow-100', text: 'text-yellow-700' },
datetime: { bg: 'bg-yellow-100', text: 'text-yellow-700' },
select: { bg: 'bg-green-100', text: 'text-green-700' },
reference: { bg: 'bg-orange-100', text: 'text-orange-700' },
url: { bg: 'bg-cyan-100', text: 'text-cyan-700' },
email: { bg: 'bg-cyan-100', text: 'text-cyan-700' },
textarea: { bg: 'bg-gray-100', text: 'text-gray-700' },
user: { bg: 'bg-pink-100', text: 'text-pink-700' },
status: { bg: 'bg-red-100', text: 'text-red-700' },
unknown: { bg: 'bg-gray-100', text: 'text-gray-500' },
};
function TypeBadge({ type }: { type: string }) {
const colors = typeColors[type] || typeColors.unknown;
return (
<span className={`inline-flex px-2 py-0.5 text-xs font-medium rounded ${colors.bg} ${colors.text}`}>
{type}
</span>
);
}
function AttributeRow({ attr, onReferenceClick }: { attr: SchemaAttributeDefinition; onReferenceClick?: (typeName: string) => void }) {
return (
<tr className="hover:bg-gray-50">
<td className="px-3 py-2 text-sm font-medium text-gray-900">
{attr.name}
{attr.isRequired && <span className="ml-1 text-red-500">*</span>}
</td>
<td className="px-3 py-2 text-sm text-gray-500 font-mono text-xs">
{attr.fieldName}
</td>
<td className="px-3 py-2">
<TypeBadge type={attr.type} />
{attr.isMultiple && (
<span className="ml-1 text-xs text-gray-400">[]</span>
)}
</td>
<td className="px-3 py-2 text-sm">
{attr.referenceTypeName ? (
<button
onClick={() => onReferenceClick?.(attr.referenceTypeName!)}
className="text-blue-600 hover:text-blue-800 hover:underline flex items-center gap-1"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
{attr.referenceTypeName}
</button>
) : (
<span className="text-gray-400"></span>
)}
</td>
<td className="px-3 py-2 text-xs text-gray-500 max-w-xs truncate" title={attr.description}>
{attr.description || '—'}
</td>
<td className="px-3 py-2 text-center">
<div className="flex gap-1 justify-center">
{attr.isSystem && (
<span className="px-1.5 py-0.5 text-xs bg-gray-200 text-gray-600 rounded" title="System attribute">
SYS
</span>
)}
{!attr.isEditable && !attr.isSystem && (
<span className="px-1.5 py-0.5 text-xs bg-yellow-100 text-yellow-700 rounded" title="Read-only">
RO
</span>
)}
</div>
</td>
</tr>
);
}
function ObjectTypeCard({
objectType,
isExpanded,
onToggle,
onReferenceClick,
onRefresh,
isRefreshing,
refreshedCount,
refreshError,
}: {
objectType: SchemaObjectTypeDefinition;
isExpanded: boolean;
onToggle: () => void;
onReferenceClick: (typeName: string) => void;
onRefresh: (typeName: string) => void;
isRefreshing: boolean;
refreshedCount?: number;
refreshError?: string;
}) {
const referenceAttrs = objectType.attributes.filter(a => a.type === 'reference');
const nonReferenceAttrs = objectType.attributes.filter(a => a.type !== 'reference');
// Use refreshed count if available, otherwise use the original objectCount
const displayCount = refreshedCount ?? objectType.objectCount;
return (
<div className="bg-white rounded-lg border border-gray-200 shadow-sm overflow-hidden">
{/* Header */}
<div
className="px-4 py-3 bg-gradient-to-r from-blue-50 to-indigo-50 border-b border-gray-200 cursor-pointer hover:from-blue-100 hover:to-indigo-100 transition-colors"
onClick={onToggle}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex-shrink-0 w-10 h-10 bg-blue-600 rounded-lg flex items-center justify-center">
<svg className="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4" />
</svg>
</div>
<div>
<h3 className="font-semibold text-gray-900">{objectType.name}</h3>
<p className="text-xs text-gray-500 font-mono">{objectType.typeName}</p>
</div>
</div>
<div className="flex items-center gap-4">
<div className="text-right">
<div className="text-sm font-medium text-gray-700">
{displayCount.toLocaleString()} objects
</div>
<div className="text-xs text-gray-500">
{objectType.attributes.length} attributes
</div>
</div>
<div className="flex gap-2">
{objectType.incomingLinks.length > 0 && (
<span className="px-2 py-1 text-xs bg-green-100 text-green-700 rounded-full" title="Incoming references">
{objectType.incomingLinks.length}
</span>
)}
{objectType.outgoingLinks.length > 0 && (
<span className="px-2 py-1 text-xs bg-orange-100 text-orange-700 rounded-full" title="Outgoing references">
{objectType.outgoingLinks.length}
</span>
)}
</div>
{/* Refresh button */}
<button
onClick={(e) => {
e.stopPropagation();
onRefresh(objectType.typeName);
}}
disabled={isRefreshing}
className={`p-2 rounded-lg transition-colors ${
isRefreshing
? 'bg-blue-100 text-blue-400 cursor-not-allowed'
: 'hover:bg-blue-100 text-blue-600 hover:text-blue-700'
}`}
title={isRefreshing ? 'Bezig met verversen...' : 'Ververs alle objecten van dit type'}
>
<svg
className={`w-4 h-4 ${isRefreshing ? 'animate-spin' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
</button>
<svg
className={`w-5 h-5 text-gray-400 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
</div>
{/* Error message */}
{refreshError && (
<div className="px-4 py-2 bg-red-50 border-b border-red-200">
<div className="flex items-center gap-2 text-sm text-red-700">
<svg className="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>{refreshError}</span>
</div>
</div>
)}
{/* Expanded Content */}
{isExpanded && (
<div className="divide-y divide-gray-100">
{/* Links Section */}
{(objectType.incomingLinks.length > 0 || objectType.outgoingLinks.length > 0) && (
<div className="px-4 py-3 bg-gray-50">
<h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">
Relationships
</h4>
<div className="grid grid-cols-2 gap-4">
{/* Incoming Links */}
{objectType.incomingLinks.length > 0 && (
<div>
<div className="text-xs font-medium text-green-700 mb-1 flex items-center gap-1">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 17l-5-5m0 0l5-5m-5 5h12" />
</svg>
Referenced by ({objectType.incomingLinks.length})
</div>
<div className="space-y-1">
{objectType.incomingLinks.map((link, idx) => (
<button
key={idx}
onClick={() => onReferenceClick(link.fromType)}
className="block text-sm text-blue-600 hover:text-blue-800 hover:underline"
>
{link.fromTypeName}
<span className="text-gray-400 text-xs ml-1">({link.attributeName})</span>
{link.isMultiple && <span className="text-gray-400 text-xs ml-0.5">[]</span>}
</button>
))}
</div>
</div>
)}
{/* Outgoing Links */}
{objectType.outgoingLinks.length > 0 && (
<div>
<div className="text-xs font-medium text-orange-700 mb-1 flex items-center gap-1">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
References ({objectType.outgoingLinks.length})
</div>
<div className="space-y-1">
{objectType.outgoingLinks.map((link, idx) => (
<button
key={idx}
onClick={() => onReferenceClick(link.toType)}
className="block text-sm text-blue-600 hover:text-blue-800 hover:underline"
>
{link.toTypeName}
<span className="text-gray-400 text-xs ml-1">({link.attributeName})</span>
{link.isMultiple && <span className="text-gray-400 text-xs ml-0.5">[]</span>}
</button>
))}
</div>
</div>
)}
</div>
</div>
)}
{/* Attributes Table */}
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Name
</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Field
</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Type
</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Reference
</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Description
</th>
<th className="px-3 py-2 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">
Flags
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-100">
{/* Reference attributes first */}
{referenceAttrs.map((attr) => (
<AttributeRow key={attr.jiraId} attr={attr} onReferenceClick={onReferenceClick} />
))}
{/* Then non-reference attributes */}
{nonReferenceAttrs.map((attr) => (
<AttributeRow key={attr.jiraId} attr={attr} onReferenceClick={onReferenceClick} />
))}
</tbody>
</table>
</div>
</div>
)}
</div>
);
}
export default function DataModelDashboard() {
const [schema, setSchema] = useState<SchemaResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [expandedTypes, setExpandedTypes] = useState<Set<string>>(new Set());
const [sortBy, setSortBy] = useState<'name' | 'objects' | 'attributes' | 'priority'>('priority');
const [refreshingTypes, setRefreshingTypes] = useState<Set<string>>(new Set());
const [refreshedCounts, setRefreshedCounts] = useState<Record<string, number>>({});
const [refreshErrors, setRefreshErrors] = useState<Record<string, string>>({});
useEffect(() => {
loadSchema();
}, []);
async function loadSchema() {
try {
setLoading(true);
setError(null);
const data = await getSchema();
setSchema(data);
// Reset refreshed counts when schema is reloaded
setRefreshedCounts({});
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load schema');
} finally {
setLoading(false);
}
}
const handleRefreshType = useCallback(async (typeName: string) => {
// Add to refreshing set and clear any previous error
setRefreshingTypes((prev) => new Set(prev).add(typeName));
setRefreshErrors((prev) => {
const next = { ...prev };
delete next[typeName];
return next;
});
try {
const result = await triggerTypeSync(typeName);
// Update the count for this type
if (result.stats?.objectsProcessed !== undefined) {
setRefreshedCounts((prev) => ({
...prev,
[typeName]: result.stats.objectsProcessed,
}));
}
} catch (err) {
console.error(`Failed to refresh ${typeName}:`, err);
// Extract error message
let errorMessage = 'Failed to sync object type';
if (err instanceof Error) {
errorMessage = err.message;
} else if (typeof err === 'object' && err !== null && 'error' in err) {
errorMessage = String(err.error);
}
setRefreshErrors((prev) => ({
...prev,
[typeName]: errorMessage,
}));
} finally {
// Remove from refreshing set
setRefreshingTypes((prev) => {
const next = new Set(prev);
next.delete(typeName);
return next;
});
}
}, []);
const filteredAndSortedTypes = useMemo(() => {
if (!schema) return [];
let types = Object.values(schema.objectTypes);
// Filter by search query
if (searchQuery) {
const query = searchQuery.toLowerCase();
types = types.filter(
(t) =>
t.name.toLowerCase().includes(query) ||
t.typeName.toLowerCase().includes(query) ||
t.attributes.some(
(a) =>
a.name.toLowerCase().includes(query) ||
a.fieldName.toLowerCase().includes(query)
)
);
}
// Sort
switch (sortBy) {
case 'name':
types.sort((a, b) => a.name.localeCompare(b.name));
break;
case 'objects':
types.sort((a, b) => b.objectCount - a.objectCount);
break;
case 'attributes':
types.sort((a, b) => b.attributes.length - a.attributes.length);
break;
case 'priority':
types.sort((a, b) => a.syncPriority - b.syncPriority || a.name.localeCompare(b.name));
break;
}
return types;
}, [schema, searchQuery, sortBy]);
const toggleExpanded = (typeName: string) => {
setExpandedTypes((prev) => {
const next = new Set(prev);
if (next.has(typeName)) {
next.delete(typeName);
} else {
next.add(typeName);
}
return next;
});
};
const handleReferenceClick = (typeName: string) => {
// Expand the referenced type and scroll to it
setExpandedTypes((prev) => new Set(prev).add(typeName));
// Find and scroll to the element
setTimeout(() => {
const element = document.getElementById(`object-type-${typeName}`);
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}, 100);
};
const expandAll = () => {
setExpandedTypes(new Set(filteredAndSortedTypes.map((t) => t.typeName)));
};
const collapseAll = () => {
setExpandedTypes(new Set());
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-center">
<div className="w-12 h-12 border-4 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
<p className="text-gray-500">Laden van datamodel...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="bg-red-50 border border-red-200 rounded-lg p-6 text-center">
<svg className="w-12 h-12 text-red-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<h3 className="text-lg font-medium text-red-800 mb-2">Fout bij laden</h3>
<p className="text-red-600 mb-4">{error}</p>
<button
onClick={loadSchema}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
>
Opnieuw proberen
</button>
</div>
);
}
if (!schema) return null;
return (
<div>
{/* Header */}
<div className="mb-8">
<h1 className="text-2xl font-bold text-gray-900">Datamodel</h1>
<p className="mt-1 text-gray-500">
Overzicht van alle object types, attributen en relaties in het Jira Assets schema.
</p>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white rounded-lg border border-gray-200 p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4" />
</svg>
</div>
<div>
<div className="text-2xl font-bold text-gray-900">{schema.metadata.objectTypeCount}</div>
<div className="text-sm text-gray-500">Object Types</div>
</div>
</div>
</div>
<div className="bg-white rounded-lg border border-gray-200 p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center">
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
</div>
<div>
<div className="text-2xl font-bold text-gray-900">{schema.metadata.totalAttributes}</div>
<div className="text-sm text-gray-500">Attributen</div>
</div>
</div>
</div>
<div className="bg-white rounded-lg border border-gray-200 p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-orange-100 rounded-lg flex items-center justify-center">
<svg className="w-5 h-5 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
</div>
<div>
<div className="text-2xl font-bold text-gray-900">
{Object.values(schema.objectTypes).reduce((sum, t) => sum + t.outgoingLinks.length, 0)}
</div>
<div className="text-sm text-gray-500">Relaties</div>
</div>
</div>
</div>
<div className="bg-white rounded-lg border border-gray-200 p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center">
<svg className="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div>
<div className="text-sm font-medium text-gray-900">
{new Date(schema.metadata.generatedAt).toLocaleDateString('nl-NL', {
day: 'numeric',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</div>
<div className="text-sm text-gray-500">Gegenereerd</div>
</div>
</div>
</div>
</div>
{/* Toolbar */}
<div className="bg-white rounded-lg border border-gray-200 p-4 mb-6">
<div className="flex flex-wrap items-center gap-4">
{/* Search */}
<div className="flex-1 min-w-[200px]">
<div className="relative">
<svg
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="text"
placeholder="Zoek object types of attributen..."
value={searchQuery}
onChange={(e) => setSearchQuery(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"
/>
</div>
</div>
{/* Sort */}
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500">Sorteren op:</span>
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as typeof sortBy)}
className="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="priority">Sync Prioriteit</option>
<option value="name">Naam</option>
<option value="objects">Aantal objecten</option>
<option value="attributes">Aantal attributen</option>
</select>
</div>
{/* Expand/Collapse */}
<div className="flex gap-2">
<button
onClick={expandAll}
className="px-3 py-2 text-sm text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors"
>
Alles uitklappen
</button>
<button
onClick={collapseAll}
className="px-3 py-2 text-sm text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors"
>
Alles inklappen
</button>
</div>
</div>
</div>
{/* Results count */}
<div className="text-sm text-gray-500 mb-4">
{filteredAndSortedTypes.length} object types
{searchQuery && ` gevonden voor "${searchQuery}"`}
</div>
{/* Object Types List */}
<div className="space-y-4">
{filteredAndSortedTypes.map((objectType) => (
<div key={objectType.typeName} id={`object-type-${objectType.typeName}`}>
<ObjectTypeCard
objectType={objectType}
isExpanded={expandedTypes.has(objectType.typeName)}
onToggle={() => toggleExpanded(objectType.typeName)}
onReferenceClick={handleReferenceClick}
onRefresh={handleRefreshType}
isRefreshing={refreshingTypes.has(objectType.typeName)}
refreshedCount={refreshedCounts[objectType.typeName]}
refreshError={refreshErrors[objectType.typeName]}
/>
</div>
))}
</div>
{filteredAndSortedTypes.length === 0 && (
<div className="text-center py-12 text-gray-500">
<svg className="w-12 h-12 mx-auto mb-4 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p>Geen object types gevonden voor "{searchQuery}"</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,258 @@
/**
* EffortDisplay - Shared component for displaying FTE effort calculations
*
* Used in:
* - ApplicationInfo.tsx (detail page)
* - GovernanceModelHelper.tsx (governance helper page)
*/
import type { EffortCalculationBreakdown } from '../types';
export interface EffortDisplayProps {
/** The effective FTE value (after override if applicable) */
effectiveFte: number | null;
/** The calculated FTE value (before override) */
calculatedFte?: number | null;
/** Override FTE value if set */
overrideFte?: number | null;
/** Full breakdown from effort calculation */
breakdown?: EffortCalculationBreakdown | null;
/** Whether this is a preview (unsaved changes) */
isPreview?: boolean;
/** Show full details (expanded view) */
showDetails?: boolean;
/** Show override input field */
showOverrideInput?: boolean;
/** Callback when override value changes */
onOverrideChange?: (value: number | null) => void;
}
// Display-only constants (for showing the formula, NOT for calculation)
// The actual calculation happens in backend/src/services/effortCalculation.ts
const HOURS_PER_WEEK_DISPLAY = 36;
const WORK_WEEKS_PER_YEAR_DISPLAY = 46;
const DECLARABLE_PERCENTAGE_DISPLAY = 0.75;
export function EffortDisplay({
effectiveFte,
calculatedFte,
overrideFte,
breakdown,
isPreview = false,
showDetails = true,
showOverrideInput = false,
onOverrideChange,
}: EffortDisplayProps) {
const hasOverride = overrideFte !== null && overrideFte !== undefined;
const hasBreakdown = breakdown !== null && breakdown !== undefined;
// Extract breakdown values
const baseEffort = breakdown?.baseEffort ?? null;
const baseEffortMin = breakdown?.baseEffortMin ?? null;
const baseEffortMax = breakdown?.baseEffortMax ?? null;
const numberOfUsersFactor = breakdown?.numberOfUsersFactor ?? { value: 1.0, name: null };
const dynamicsFactor = breakdown?.dynamicsFactor ?? { value: 1.0, name: null };
const complexityFactor = breakdown?.complexityFactor ?? { value: 1.0, name: null };
const governanceModelName = breakdown?.governanceModelName ?? breakdown?.governanceModel ?? null;
const applicationTypeName = breakdown?.applicationType ?? null;
const businessImpactAnalyse = breakdown?.businessImpactAnalyse ?? null;
const applicationManagementHosting = breakdown?.applicationManagementHosting ?? null;
const warnings = breakdown?.warnings ?? [];
const errors = breakdown?.errors ?? [];
const usedDefaults = breakdown?.usedDefaults ?? [];
const requiresManualAssessment = breakdown?.requiresManualAssessment ?? false;
const isFixedFte = breakdown?.isFixedFte ?? false;
// Use hours from backend breakdown (calculated in effortCalculation.ts)
// Only fall back to local calculation if breakdown is not available
const declarableHoursPerYear = breakdown?.hoursPerYear ?? (effectiveFte !== null
? HOURS_PER_WEEK_DISPLAY * WORK_WEEKS_PER_YEAR_DISPLAY * effectiveFte * DECLARABLE_PERCENTAGE_DISPLAY
: 0);
const hoursPerMonth = breakdown?.hoursPerMonth ?? declarableHoursPerYear / 12;
const hoursPerWeekCalculated = breakdown?.hoursPerWeek ?? declarableHoursPerYear / WORK_WEEKS_PER_YEAR_DISPLAY;
const minutesPerWeek = hoursPerWeekCalculated * 60;
// For display of netto hours (before declarable percentage)
const netHoursPerYear = effectiveFte !== null
? HOURS_PER_WEEK_DISPLAY * WORK_WEEKS_PER_YEAR_DISPLAY * effectiveFte
: 0;
// No effort calculated
if (effectiveFte === null || effectiveFte === undefined) {
if (errors.length > 0) {
return (
<div className="space-y-2">
<div className="bg-red-50 border border-red-200 rounded-lg p-2">
{errors.map((error, i) => (
<div key={i} className="text-sm text-red-700 flex items-start gap-1">
<span></span>
<span>{error}</span>
</div>
))}
</div>
<span className="text-sm text-gray-400">Niet berekend - configuratie onvolledig</span>
</div>
);
}
return <span className="text-sm text-gray-400">Niet berekend</span>;
}
return (
<div className="space-y-2">
{/* Errors */}
{errors.length > 0 && (
<div className="bg-red-50 border border-red-200 rounded-lg p-2 mb-2">
{errors.map((error, i) => (
<div key={i} className="text-sm text-red-700 flex items-start gap-1">
<span></span>
<span>{error}</span>
</div>
))}
</div>
)}
{/* Warnings */}
{warnings.length > 0 && (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-2 mb-2">
{warnings.map((warning, i) => (
<div key={i} className="text-sm text-yellow-700 flex items-start gap-1">
<span>{warning.startsWith('⚠️') || warning.startsWith('') ? '' : ''}</span>
<span>{warning}</span>
</div>
))}
</div>
)}
{/* Main FTE display */}
<div className="text-lg font-semibold text-gray-900">
{effectiveFte.toFixed(2)} FTE
{hasOverride && (
<span className="ml-2 text-sm font-normal text-orange-600">(Override)</span>
)}
{isPreview && !hasOverride && (
<span className="ml-2 text-sm font-normal text-blue-600">(voorvertoning)</span>
)}
{isFixedFte && (
<span className="ml-2 text-sm font-normal text-purple-600">(vast)</span>
)}
{requiresManualAssessment && (
<span className="ml-2 text-sm font-normal text-orange-600">(handmatige beoordeling)</span>
)}
</div>
{/* Show calculated value if override is active */}
{hasOverride && calculatedFte !== null && calculatedFte !== undefined && (
<div className="text-sm text-gray-600">
Berekende waarde: <span className="font-medium">{calculatedFte.toFixed(2)} FTE</span>
</div>
)}
{/* Override input */}
{showOverrideInput && onOverrideChange && (
<div className="flex items-center gap-2 mt-2">
<label className="text-sm text-gray-600">Override FTE:</label>
<input
type="number"
step="0.01"
min="0"
value={overrideFte !== null ? overrideFte : ''}
onChange={(e) => {
const value = e.target.value;
if (value === '') {
onOverrideChange(null);
} else {
const numValue = parseFloat(value);
if (!isNaN(numValue)) {
onOverrideChange(numValue);
}
}
}}
className="w-24 px-2 py-1 border border-gray-300 rounded text-sm"
placeholder="Leeg"
/>
</div>
)}
{showDetails && baseEffort !== null && (
<div className="pt-2 border-t border-gray-200 space-y-1 text-sm text-gray-600">
{/* Base FTE with range */}
<div className="font-medium text-gray-700 mb-2">
Basis FTE: {baseEffort.toFixed(2)} FTE
{baseEffortMin !== null && baseEffortMax !== null && baseEffortMin !== baseEffortMax && (
<span className="text-xs text-gray-500 ml-1">
(range: {baseEffortMin.toFixed(2)} - {baseEffortMax.toFixed(2)})
</span>
)}
</div>
{/* Lookup path */}
<div className="pl-2 space-y-1 text-xs text-gray-500 border-l-2 border-gray-300">
<div className="flex items-center gap-1">
<span>ICT Governance Model:</span>
<span className="font-medium text-gray-700">{governanceModelName || 'Niet ingesteld'}</span>
{usedDefaults.includes('regiemodel') && <span className="text-orange-500">(default)</span>}
</div>
<div className="flex items-center gap-1">
<span>Application Type:</span>
<span className="font-medium text-gray-700">{applicationTypeName || 'Niet ingesteld'}</span>
{usedDefaults.includes('applicationType') && <span className="text-orange-500">(default)</span>}
</div>
<div className="flex items-center gap-1">
<span>Business Impact Analyse:</span>
<span className="font-medium text-gray-700">{businessImpactAnalyse || 'Niet ingesteld'}</span>
{usedDefaults.includes('businessImpact') && <span className="text-orange-500">(default)</span>}
</div>
<div className="flex items-center gap-1">
<span>Hosting:</span>
<span className="font-medium text-gray-700">{applicationManagementHosting || 'Niet ingesteld'}</span>
{usedDefaults.includes('hosting') && <span className="text-orange-500">(default)</span>}
</div>
</div>
{/* Factors */}
<div className="font-medium text-gray-700 mt-2 mb-1">Factoren:</div>
<div>
Number of Users: × {numberOfUsersFactor.value.toFixed(2)}
{numberOfUsersFactor.name && ` (${numberOfUsersFactor.name})`}
</div>
<div>
Dynamics Factor: × {dynamicsFactor.value.toFixed(2)}
{dynamicsFactor.name && ` (${dynamicsFactor.name})`}
</div>
<div>
Complexity Factor: × {complexityFactor.value.toFixed(2)}
{complexityFactor.name && ` (${complexityFactor.name})`}
</div>
{/* Hours breakdown */}
<div className="font-medium text-gray-700 mt-3 mb-1 pt-2 border-t border-gray-200">
Uren per jaar (écht inzetbaar):
</div>
<div className="pl-2 space-y-1 text-xs text-gray-600 bg-blue-50 rounded p-2 border-l-2 border-blue-300">
<div className="font-medium text-gray-700">
{declarableHoursPerYear.toFixed(1)} uur per jaar
</div>
<div className="text-gray-500 mt-1">
{hoursPerMonth.toFixed(1)} uur per maand
</div>
<div className="text-gray-500">
{hoursPerWeekCalculated.toFixed(2)} uur per week
</div>
<div className="text-gray-500">
{minutesPerWeek.toFixed(0)} minuten per week
</div>
<div className="text-xs text-gray-400 mt-2 pt-2 border-t border-gray-200">
<div>Berekening: {HOURS_PER_WEEK_DISPLAY} uur/week × {WORK_WEEKS_PER_YEAR_DISPLAY} weken × {effectiveFte.toFixed(2)} FTE × {DECLARABLE_PERCENTAGE_DISPLAY * 100}% = {declarableHoursPerYear.toFixed(1)} uur/jaar</div>
<div className="mt-1">(Netto: {netHoursPerYear.toFixed(1)} uur/jaar, waarvan {DECLARABLE_PERCENTAGE_DISPLAY * 100}% declarabel)</div>
</div>
</div>
</div>
)}
</div>
);
}
export default EffortDisplay;

View File

@@ -0,0 +1,337 @@
import { useEffect, useState, useMemo } from 'react';
import { getReferenceData } from '../services/api';
import CustomSelect from './CustomSelect';
import { EffortDisplay } from './EffortDisplay';
import { useEffortCalculation, getEffectiveFte } from '../hooks/useEffortCalculation';
import type { ReferenceValue, ApplicationDetails } from '../types';
export default function FTECalculator() {
// Reference data state
const [governanceModels, setGovernanceModels] = useState<ReferenceValue[]>([]);
const [applicationTypes, setApplicationTypes] = useState<ReferenceValue[]>([]);
const [businessImpactAnalyses, setBusinessImpactAnalyses] = useState<ReferenceValue[]>([]);
const [applicationManagementHosting, setApplicationManagementHosting] = useState<ReferenceValue[]>([]);
const [numberOfUsers, setNumberOfUsers] = useState<ReferenceValue[]>([]);
const [dynamicsFactors, setDynamicsFactors] = useState<ReferenceValue[]>([]);
const [complexityFactors, setComplexityFactors] = useState<ReferenceValue[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Selected values state
const [selectedGovernanceModel, setSelectedGovernanceModel] = useState<ReferenceValue | null>(null);
const [selectedApplicationType, setSelectedApplicationType] = useState<ReferenceValue | null>(null);
const [selectedBusinessImpactAnalyse, setSelectedBusinessImpactAnalyse] = useState<ReferenceValue | null>(null);
const [selectedHosting, setSelectedHosting] = useState<ReferenceValue | null>(null);
const [selectedNumberOfUsers, setSelectedNumberOfUsers] = useState<ReferenceValue | null>(null);
const [selectedDynamicsFactor, setSelectedDynamicsFactor] = useState<ReferenceValue | null>(null);
const [selectedComplexityFactor, setSelectedComplexityFactor] = useState<ReferenceValue | null>(null);
// Load reference data
useEffect(() => {
async function loadData() {
try {
setLoading(true);
setError(null);
const data = await getReferenceData();
setGovernanceModels(data.governanceModels);
setApplicationTypes(data.applicationTypes);
setBusinessImpactAnalyses(data.businessImpactAnalyses);
setApplicationManagementHosting(data.applicationManagementHosting);
setNumberOfUsers(data.numberOfUsers);
setDynamicsFactors(data.dynamicsFactors);
setComplexityFactors(data.complexityFactors);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load reference data');
} finally {
setLoading(false);
}
}
loadData();
}, []);
// Build a minimal ApplicationDetails object for calculation
const applicationData = useMemo<ApplicationDetails | null>(() => {
// Only create if at least governance model is selected (required for calculation)
if (!selectedGovernanceModel) return null;
return {
id: 'calculator',
key: 'CALC',
name: 'FTE Calculator',
searchReference: null,
description: null,
supplierProduct: null,
organisation: null,
hostingType: null,
status: null,
businessImportance: null,
businessImpactAnalyse: selectedBusinessImpactAnalyse,
systemOwner: null,
businessOwner: null,
functionalApplicationManagement: null,
technicalApplicationManagement: null,
medischeTechniek: false,
applicationFunctions: [],
dynamicsFactor: selectedDynamicsFactor,
complexityFactor: selectedComplexityFactor,
numberOfUsers: selectedNumberOfUsers,
governanceModel: selectedGovernanceModel,
applicationSubteam: null,
applicationTeam: null,
applicationType: selectedApplicationType,
platform: null,
requiredEffortApplicationManagement: null,
applicationManagementHosting: selectedHosting,
applicationManagementTAM: null,
};
}, [
selectedGovernanceModel,
selectedApplicationType,
selectedBusinessImpactAnalyse,
selectedHosting,
selectedNumberOfUsers,
selectedDynamicsFactor,
selectedComplexityFactor,
]);
// Use effort calculation hook
const {
calculatedFte,
breakdown: effortBreakdown,
isCalculating,
} = useEffortCalculation({
application: applicationData,
debounceMs: 300,
});
// Reset all fields
const handleReset = () => {
setSelectedGovernanceModel(null);
setSelectedApplicationType(null);
setSelectedBusinessImpactAnalyse(null);
setSelectedHosting(null);
setSelectedNumberOfUsers(null);
setSelectedDynamicsFactor(null);
setSelectedComplexityFactor(null);
};
// Sort numberOfUsers by extracting the first number from each option
const sortedNumberOfUsers = useMemo(() => {
const getSortValue = (name: string): number => {
const cleaned = name.replace(/\./g, '');
const match = cleaned.match(/\d+/);
const num = match ? parseInt(match[0], 10) : 0;
if (name.startsWith('<')) {
return num - 0.5;
}
if (name.startsWith('>')) {
return num + 0.5;
}
return num;
};
return [...numberOfUsers].sort((a, b) => {
const numA = getSortValue(a.name);
const numB = getSortValue(b.name);
return numA - numB;
});
}, [numberOfUsers]);
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
</div>
);
}
if (error) {
return (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-red-700">
{error}
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="bg-white rounded-lg border border-gray-200 p-6">
<h1 className="text-2xl font-bold text-gray-900 mb-2">FTE Calculator</h1>
<p className="text-gray-600">
Bereken de benodigde FTE voor applicatiemanagement op basis van de onderstaande classificatievelden.
</p>
</div>
{/* Form */}
<div className="bg-white rounded-lg border border-gray-200 p-6">
<div className="flex justify-between items-center mb-6">
<h2 className="text-lg font-semibold text-gray-900">Classificatievelden</h2>
<button
onClick={handleReset}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
>
Reset alle velden
</button>
</div>
<div className="space-y-6">
{/* First row: Application Type, Hosting */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Application Type */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Application Type
</label>
<CustomSelect
value={selectedApplicationType?.objectId || ''}
onChange={(value) => {
const selected = applicationTypes.find((t) => t.objectId === value);
setSelectedApplicationType(selected || null);
}}
options={applicationTypes}
placeholder="Selecteer Application Type..."
showSummary={true}
/>
</div>
{/* Hosting */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Hosting
</label>
<CustomSelect
value={selectedHosting?.objectId || ''}
onChange={(value) => {
const selected = applicationManagementHosting.find((h) => h.objectId === value);
setSelectedHosting(selected || null);
}}
options={applicationManagementHosting}
placeholder="Selecteer Hosting..."
/>
</div>
</div>
{/* Second row: Business Impact Analyse - Full width */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Business Impact Analyse
</label>
<CustomSelect
value={selectedBusinessImpactAnalyse?.objectId || ''}
onChange={(value) => {
const selected = businessImpactAnalyses.find((b) => b.objectId === value);
setSelectedBusinessImpactAnalyse(selected || null);
}}
options={businessImpactAnalyses}
placeholder="Selecteer Business Impact Analyse..."
showSummary={true}
/>
</div>
{/* Third row: Number of Users, Dynamics Factor, Complexity Factor - 3 columns */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Number of Users */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Number of Users
</label>
<CustomSelect
value={selectedNumberOfUsers?.objectId || ''}
onChange={(value) => {
const selected = sortedNumberOfUsers.find((u) => u.objectId === value);
setSelectedNumberOfUsers(selected || null);
}}
options={sortedNumberOfUsers}
placeholder="Selecteer Number of Users..."
showSummary={true}
/>
</div>
{/* Dynamics Factor */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Dynamics Factor
</label>
<CustomSelect
value={selectedDynamicsFactor?.objectId || ''}
onChange={(value) => {
const selected = dynamicsFactors.find((d) => d.objectId === value);
setSelectedDynamicsFactor(selected || null);
}}
options={dynamicsFactors}
placeholder="Selecteer Dynamics Factor..."
showSummary={true}
/>
</div>
{/* Complexity Factor */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Complexity Factor
</label>
<CustomSelect
value={selectedComplexityFactor?.objectId || ''}
onChange={(value) => {
const selected = complexityFactors.find((c) => c.objectId === value);
setSelectedComplexityFactor(selected || null);
}}
options={complexityFactors}
placeholder="Selecteer Complexity Factor..."
showSummary={true}
/>
</div>
</div>
</div>
{/* ICT Governance Model - Full width at the end */}
<div className="mt-6">
<label className="block text-sm font-medium text-gray-700 mb-2">
ICT Governance Model <span className="text-red-500">*</span>
</label>
<CustomSelect
value={selectedGovernanceModel?.objectId || ''}
onChange={(value) => {
const selected = governanceModels.find((m) => m.objectId === value);
setSelectedGovernanceModel(selected || null);
}}
options={governanceModels}
placeholder="Selecteer ICT Governance Model..."
showSummary={true}
/>
</div>
</div>
{/* Result */}
<div className="bg-white rounded-lg border border-gray-200 p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Berekening Resultaat</h2>
{isCalculating ? (
<div className="flex items-center gap-2 text-gray-600">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600" />
<span>Berekenen...</span>
</div>
) : (
<div className="w-full border border-gray-300 rounded-lg px-3 py-2 bg-gray-50">
<EffortDisplay
effectiveFte={getEffectiveFte(calculatedFte, null, null)}
calculatedFte={calculatedFte ?? null}
overrideFte={null}
breakdown={effortBreakdown}
isPreview={true}
showDetails={true}
showOverrideInput={false}
/>
</div>
)}
{!selectedGovernanceModel && (
<p className="mt-4 text-sm text-gray-500">
Selecteer minimaal het ICT Governance Model om een berekening uit te voeren.
</p>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,303 @@
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
interface ApplicationWithIssues {
id: string;
key: string;
name: string;
status: string | null;
governanceModel: string | null;
businessImpactAnalyse: string | null;
applicationType: string | null;
warnings: string[];
errors: string[];
}
interface GovernanceAnalysisData {
totalApplications: number;
applicationsWithIssues: number;
applications: ApplicationWithIssues[];
}
const API_BASE = '/api';
// Default statuses to exclude
const DEFAULT_EXCLUDED_STATUSES = ['Closed', 'Deprecated'];
export default function GovernanceAnalysis() {
const [data, setData] = useState<GovernanceAnalysisData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [excludedStatuses, setExcludedStatuses] = useState<string[]>(DEFAULT_EXCLUDED_STATUSES);
useEffect(() => {
async function fetchData() {
setLoading(true);
setError(null);
try {
const response = await fetch(`${API_BASE}/dashboard/governance-analysis`);
if (!response.ok) {
throw new Error('Failed to fetch governance analysis');
}
const result = await response.json();
setData(result);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load data');
} finally {
setLoading(false);
}
}
fetchData();
}, []);
// Get unique statuses from the data for filtering
const availableStatuses = Array.from(
new Set(data?.applications.map(app => app.status).filter(Boolean) as string[])
).sort();
// Filter applications
const filteredApplications = data?.applications.filter(app => {
// Filter by excluded statuses
if (app.status && excludedStatuses.includes(app.status)) return false;
// Filter by search query
if (searchQuery) {
const query = searchQuery.toLowerCase();
return (
app.name.toLowerCase().includes(query) ||
app.key.toLowerCase().includes(query) ||
(app.governanceModel?.toLowerCase().includes(query)) ||
(app.businessImpactAnalyse?.toLowerCase().includes(query))
);
}
return true;
}) || [];
// Toggle status exclusion
const toggleStatusExclusion = (status: string) => {
setExcludedStatuses(prev =>
prev.includes(status)
? prev.filter(s => s !== status)
: [...prev, status]
);
};
// Applications filtered by status (before type/search filtering)
const statusFilteredApplications = data?.applications.filter(app =>
!(app.status && excludedStatuses.includes(app.status))
) || [];
// Count statistics based on status-filtered applications
const totalWithIssues = statusFilteredApplications.length;
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-4" />
<p className="text-gray-500">Analyseren van regiemodel configuratie...</p>
<p className="text-gray-400 text-sm mt-1">Dit kan even duren...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-red-700">
<div className="flex items-center gap-2">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{error}
</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex justify-between items-start">
<div>
<h1 className="text-2xl font-bold text-gray-900">Analyse Regiemodel</h1>
<p className="mt-1 text-gray-500">
Overzicht van applicaties met regiemodel fouten (ongeldig regiemodel voor de BIA classificatie).
<br />
<span className="text-sm text-gray-400">Standaard worden Closed en Deprecated applicaties uitgesloten.</span>
</p>
</div>
<Link
to="/reports"
className="text-sm text-blue-600 hover:text-blue-700 flex items-center gap-1"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Terug naar rapporten
</Link>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-white rounded-lg border border-gray-200 p-4">
<div className="text-sm text-gray-500">Totaal geanalyseerd</div>
<div className="text-2xl font-bold text-gray-900">{data?.totalApplications || 0}</div>
</div>
<div className="bg-white rounded-lg border border-gray-200 p-4">
<div className="text-sm text-gray-500">Met regiemodel fouten</div>
<div className="text-2xl font-bold text-red-600">{totalWithIssues}</div>
{excludedStatuses.length > 0 && data && totalWithIssues !== data.applicationsWithIssues && (
<div className="text-xs text-gray-400 mt-1">
({data.applicationsWithIssues} totaal, {data.applicationsWithIssues - totalWithIssues} uitgesloten)
</div>
)}
</div>
</div>
{/* Filters */}
<div className="bg-white rounded-lg border border-gray-200 p-4 space-y-4">
<div className="flex flex-wrap items-center gap-4">
{/* Search */}
<div className="flex-1 min-w-[200px]">
<div className="relative">
<input
type="text"
placeholder="Zoeken op naam, key, regiemodel..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
<svg
className="absolute left-3 top-2.5 h-5 w-5 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
</div>
</div>
{/* Status exclusion filter */}
{availableStatuses.length > 0 && (
<div className="pt-2 border-t border-gray-100">
<div className="flex flex-wrap items-center gap-3">
<span className="text-sm text-gray-600">Status uitsluiten:</span>
{availableStatuses.map((status) => (
<label key={status} className="flex items-center gap-1.5 cursor-pointer">
<input
type="checkbox"
checked={excludedStatuses.includes(status)}
onChange={() => toggleStatusExclusion(status)}
className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className={`text-sm ${excludedStatuses.includes(status) ? 'text-gray-400 line-through' : 'text-gray-700'}`}>
{status}
</span>
</label>
))}
{excludedStatuses.length > 0 && (
<button
onClick={() => setExcludedStatuses([])}
className="text-xs text-blue-600 hover:text-blue-800 ml-2"
>
Alles tonen
</button>
)}
</div>
</div>
)}
</div>
{/* Results */}
<div className="bg-white rounded-lg border border-gray-200">
<div className="px-4 py-3 border-b border-gray-200">
<span className="text-sm text-gray-600">
{filteredApplications.length} applicatie{filteredApplications.length !== 1 ? 's' : ''} gevonden
</span>
</div>
{filteredApplications.length === 0 ? (
<div className="text-center py-12">
<svg className="w-12 h-12 text-green-300 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p className="text-gray-500">Geen applicaties met regiemodel fouten gevonden</p>
<p className="text-gray-400 text-sm mt-1">Alle applicaties hebben een geldig regiemodel voor hun BIA classificatie</p>
</div>
) : (
<div className="divide-y divide-gray-200">
{filteredApplications.map((app) => (
<div key={app.id} className="p-4 hover:bg-gray-50">
<div className="flex items-start justify-between">
<div className="flex-1">
<Link
to={`/app-components/overview/${app.id}`}
className="text-blue-600 hover:text-blue-800 font-medium hover:underline"
>
{app.name}
</Link>
<div className="flex flex-wrap items-center gap-2 mt-1 text-sm text-gray-500">
<span className="font-mono">{app.key}</span>
{app.status && (
<span className="px-2 py-0.5 bg-gray-100 rounded text-xs">{app.status}</span>
)}
{app.governanceModel && (
<span className="px-2 py-0.5 bg-blue-100 text-blue-700 rounded text-xs">
{app.governanceModel}
</span>
)}
{app.businessImpactAnalyse && (
<span className="px-2 py-0.5 bg-purple-100 text-purple-700 rounded text-xs">
BIA: {app.businessImpactAnalyse}
</span>
)}
{app.applicationType && (
<span className="px-2 py-0.5 bg-green-100 text-green-700 rounded text-xs">
{app.applicationType}
</span>
)}
</div>
</div>
</div>
{/* Errors */}
{app.errors.length > 0 && (
<div className="mt-3 bg-red-50 border border-red-200 rounded-lg p-3">
{app.errors.map((error, i) => (
<div key={i} className="text-sm text-red-700 flex items-start gap-2">
<span className="flex-shrink-0"></span>
<span>{error}</span>
</div>
))}
</div>
)}
{/* Warnings */}
{app.warnings.length > 0 && (
<div className="mt-3 bg-yellow-50 border border-yellow-200 rounded-lg p-3">
{app.warnings.map((warning, i) => (
<div key={i} className="text-sm text-yellow-700 flex items-start gap-2">
<span className="flex-shrink-0">
{warning.startsWith('⚠️') || warning.startsWith('') ? '' : ''}
</span>
<span>{warning}</span>
</div>
))}
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -63,7 +63,7 @@ export default function Login() {
</div> </div>
)} )}
{config?.oauthEnabled ? ( {config?.authMethod === 'oauth' ? (
<> <>
<button <button
onClick={handleJiraLogin} onClick={handleJiraLogin}
@@ -76,19 +76,19 @@ export default function Login() {
</button> </button>
<p className="mt-4 text-center text-slate-500 text-sm"> <p className="mt-4 text-center text-slate-500 text-sm">
Je wordt doorgestuurd naar Jira om in te loggen Je wordt doorgestuurd naar Jira om in te loggen met OAuth 2.0
</p> </p>
</> </>
) : config?.serviceAccountEnabled ? ( ) : config?.authMethod === 'pat' ? (
<div className="text-center"> <div className="text-center">
<div className="inline-flex items-center justify-center w-12 h-12 bg-green-500/20 rounded-full mb-4"> <div className="inline-flex items-center justify-center w-12 h-12 bg-green-500/20 rounded-full mb-4">
<svg className="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg> </svg>
</div> </div>
<p className="text-slate-300 mb-2">Service Account Modus</p> <p className="text-slate-300 mb-2">Personal Access Token Modus</p>
<p className="text-slate-500 text-sm"> <p className="text-slate-500 text-sm">
De applicatie gebruikt een geconfigureerd service account voor Jira toegang. De applicatie gebruikt een geconfigureerd Personal Access Token (PAT) voor Jira toegang.
</p> </p>
<button <button
onClick={() => window.location.reload()} onClick={() => window.location.reload()}
@@ -106,7 +106,7 @@ export default function Login() {
</div> </div>
<p className="text-slate-300 mb-2">Niet geconfigureerd</p> <p className="text-slate-300 mb-2">Niet geconfigureerd</p>
<p className="text-slate-500 text-sm"> <p className="text-slate-500 text-sm">
Neem contact op met de beheerder om OAuth of een service account te configureren. Neem contact op met de beheerder om OAuth of een Personal Access Token te configureren.
</p> </p>
</div> </div>
)} )}

View File

@@ -0,0 +1,165 @@
import { Link } from 'react-router-dom';
export default function ReportsDashboard() {
const reports = [
{
id: 'team-dashboard',
title: 'Team-indeling',
description: 'Overzicht van teams, subteams en FTE verdeling.',
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
),
href: '/reports/team-dashboard',
color: 'blue',
available: true,
},
{
id: 'governance-analysis',
title: 'Analyse Regiemodel',
description: 'Overzicht van applicaties met regiemodel en BIA configuratie problemen.',
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
),
href: '/reports/governance-analysis',
color: 'orange',
available: true,
},
{
id: 'classification-progress',
title: 'Classificatie Voortgang',
description: 'Voortgang van ZiRA classificatie per domein en afdeling.',
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
),
href: '/reports/classification-progress',
color: 'green',
available: false,
},
{
id: 'governance-overview',
title: 'Regiemodel Overzicht',
description: 'Verdeling van applicaties per regiemodel en BIA classificatie.',
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 3.055A9.001 9.001 0 1020.945 13H11V3.055z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.488 9H15V3.512A9.025 9.025 0 0120.488 9z" />
</svg>
),
href: '/reports/governance-overview',
color: 'purple',
available: false,
},
{
id: 'data-model',
title: 'Datamodel',
description: 'Overzicht van alle object types, attributen en relaties in het Jira Assets schema.',
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
</svg>
),
href: '/reports/data-model',
color: 'cyan',
available: true,
},
];
const colorClasses = {
blue: {
bg: 'bg-blue-50',
iconBg: 'bg-blue-100',
iconText: 'text-blue-600',
hover: 'hover:bg-blue-100',
},
green: {
bg: 'bg-green-50',
iconBg: 'bg-green-100',
iconText: 'text-green-600',
hover: 'hover:bg-green-100',
},
purple: {
bg: 'bg-purple-50',
iconBg: 'bg-purple-100',
iconText: 'text-purple-600',
hover: 'hover:bg-purple-100',
},
orange: {
bg: 'bg-orange-50',
iconBg: 'bg-orange-100',
iconText: 'text-orange-600',
hover: 'hover:bg-orange-100',
},
cyan: {
bg: 'bg-cyan-50',
iconBg: 'bg-cyan-100',
iconText: 'text-cyan-600',
hover: 'hover:bg-cyan-100',
},
};
return (
<div>
{/* Header */}
<div className="mb-8">
<h1 className="text-2xl font-bold text-gray-900">Rapporten</h1>
<p className="mt-1 text-gray-500">
Overzicht van beschikbare rapporten en analyses voor de CMDB.
</p>
</div>
{/* Reports Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{reports.map((report) => {
const colors = colorClasses[report.color as keyof typeof colorClasses];
if (!report.available) {
return (
<div
key={report.id}
className={`relative p-6 rounded-xl border border-gray-200 bg-gray-50 opacity-60`}
>
<div className="absolute top-4 right-4">
<span className="px-2 py-1 text-xs font-medium bg-gray-200 text-gray-600 rounded-full">
Binnenkort
</span>
</div>
<div className={`w-12 h-12 rounded-xl ${colors.iconBg} flex items-center justify-center mb-4`}>
<span className={colors.iconText}>{report.icon}</span>
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">{report.title}</h3>
<p className="text-gray-500 text-sm">{report.description}</p>
</div>
);
}
return (
<Link
key={report.id}
to={report.href}
className={`p-6 rounded-xl border border-gray-200 ${colors.bg} ${colors.hover} transition-colors group`}
>
<div className={`w-12 h-12 rounded-xl ${colors.iconBg} flex items-center justify-center mb-4 group-hover:scale-110 transition-transform`}>
<span className={colors.iconText}>{report.icon}</span>
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">{report.title}</h3>
<p className="text-gray-500 text-sm">{report.description}</p>
<div className="mt-4 flex items-center text-sm font-medium text-blue-600">
Bekijk rapport
<svg className="w-4 h-4 ml-1 group-hover:translate-x-1 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
</Link>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,548 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { searchCMDB, getConfig, CMDBSearchResponse, CMDBSearchResult, CMDBSearchObjectType } from '../services/api';
const ITEMS_PER_PAGE = 25;
const APPLICATION_COMPONENT_TYPE_NAME = 'ApplicationComponent';
// Helper to strip HTML tags from description
function stripHtml(html: string): string {
return html
.replace(/<[^>]*>/g, '')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/\s+/g, ' ')
.trim();
}
// Helper to get attribute value from result
function getAttributeValue(result: CMDBSearchResult, attributeName: string): string | null {
const attr = result.attributes.find(a => a.name === attributeName);
if (attr && attr.values && attr.values.length > 0) {
return attr.values[0];
}
return null;
}
// Helper to get status display info
function getStatusInfo(status: string | null): { color: string; bg: string } {
if (!status) return { color: 'text-gray-600', bg: 'bg-gray-100' };
const statusLower = status.toLowerCase();
if (statusLower.includes('production')) return { color: 'text-green-700', bg: 'bg-green-100' };
if (statusLower.includes('implementation')) return { color: 'text-blue-700', bg: 'bg-blue-100' };
if (statusLower.includes('deprecated') || statusLower.includes('end of')) return { color: 'text-orange-700', bg: 'bg-orange-100' };
if (statusLower.includes('closed')) return { color: 'text-red-700', bg: 'bg-red-100' };
if (statusLower.includes('concept') || statusLower.includes('poc')) return { color: 'text-purple-700', bg: 'bg-purple-100' };
if (statusLower.includes('shadow')) return { color: 'text-yellow-700', bg: 'bg-yellow-100' };
return { color: 'text-gray-600', bg: 'bg-gray-100' };
}
export default function SearchDashboard() {
const navigate = useNavigate();
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<CMDBSearchResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedTab, setSelectedTab] = useState<number | null>(null);
const [statusFilter, setStatusFilter] = useState<string>('');
const [currentPage, setCurrentPage] = useState<Map<number, number>>(new Map());
const [jiraHost, setJiraHost] = useState<string>('');
const [hasSearched, setHasSearched] = useState(false);
// Fetch Jira host for avatar URLs
useEffect(() => {
getConfig().then(config => {
setJiraHost(config.jiraHost);
}).catch(() => {
// Silently fail, avatars just won't show
});
}, []);
// Group results by object type
const resultsByType = useMemo(() => {
if (!searchResults?.results) return new Map<number, CMDBSearchResult[]>();
const grouped = new Map<number, CMDBSearchResult[]>();
for (const result of searchResults.results) {
const typeId = result.objectTypeId;
if (!grouped.has(typeId)) {
grouped.set(typeId, []);
}
grouped.get(typeId)!.push(result);
}
return grouped;
}, [searchResults]);
// Get object type info by ID
const objectTypeMap = useMemo(() => {
if (!searchResults?.objectTypes) return new Map<number, CMDBSearchObjectType>();
const map = new Map<number, CMDBSearchObjectType>();
for (const ot of searchResults.objectTypes) {
map.set(ot.id, ot);
}
return map;
}, [searchResults]);
// Get sorted object types (by result count, descending)
const sortedObjectTypes = useMemo(() => {
if (!searchResults?.objectTypes) return [];
return [...searchResults.objectTypes].sort((a, b) => {
const countA = resultsByType.get(a.id)?.length || 0;
const countB = resultsByType.get(b.id)?.length || 0;
return countB - countA;
});
}, [searchResults, resultsByType]);
// Current tab's results
const currentTabResults = useMemo(() => {
if (selectedTab === null) return [];
return resultsByType.get(selectedTab) || [];
}, [selectedTab, resultsByType]);
// Get unique status values for current tab
const statusOptions = useMemo(() => {
const statuses = new Set<string>();
for (const result of currentTabResults) {
const status = getAttributeValue(result, 'Status');
if (status) {
// Handle status objects with nested structure (null check required because typeof null === 'object')
const statusName = status && typeof status === 'object' && (status as any).name
? (status as any).name
: status;
statuses.add(statusName);
}
}
return Array.from(statuses).sort();
}, [currentTabResults]);
// Filter results by status
const filteredResults = useMemo(() => {
if (!statusFilter) return currentTabResults;
return currentTabResults.filter(result => {
const status = getAttributeValue(result, 'Status');
if (!status) return false;
// Handle status objects with nested structure (null check required because typeof null === 'object')
const statusName = status && typeof status === 'object' && (status as any).name
? (status as any).name
: status;
return statusName === statusFilter;
});
}, [currentTabResults, statusFilter]);
// Pagination
const pageForCurrentTab = currentPage.get(selectedTab || 0) || 1;
const totalPages = Math.ceil(filteredResults.length / ITEMS_PER_PAGE);
const paginatedResults = filteredResults.slice(
(pageForCurrentTab - 1) * ITEMS_PER_PAGE,
pageForCurrentTab * ITEMS_PER_PAGE
);
// Reset pagination when filter changes
useEffect(() => {
if (selectedTab !== null) {
setCurrentPage(prev => new Map(prev).set(selectedTab, 1));
}
}, [statusFilter, selectedTab]);
// Auto-select first tab when results arrive
useEffect(() => {
if (sortedObjectTypes.length > 0 && selectedTab === null) {
setSelectedTab(sortedObjectTypes[0].id);
}
}, [sortedObjectTypes, selectedTab]);
// Perform search
const handleSearch = useCallback((e?: React.FormEvent) => {
e?.preventDefault();
if (!searchQuery.trim()) return;
setLoading(true);
setError(null);
setHasSearched(true);
setSelectedTab(null);
setStatusFilter('');
setCurrentPage(new Map());
searchCMDB(searchQuery.trim())
.then((results) => {
setSearchResults(results);
// Auto-select first tab if results exist
if (results.objectTypes && results.objectTypes.length > 0) {
// Sort by count and select the first one
const sorted = [...results.objectTypes].sort((a, b) => {
const countA = results.results.filter(r => r.objectTypeId === a.id).length;
const countB = results.results.filter(r => r.objectTypeId === b.id).length;
return countB - countA;
});
setSelectedTab(sorted[0].id);
}
})
.catch((err) => {
setError(err instanceof Error ? err.message : 'Zoeken mislukt');
setSearchResults(null);
})
.finally(() => {
setLoading(false);
});
}, [searchQuery]);
// Handle tab change
const handleTabChange = (typeId: number) => {
setSelectedTab(typeId);
setStatusFilter('');
};
// Handle page change
const handlePageChange = (newPage: number) => {
if (selectedTab !== null) {
setCurrentPage(prev => new Map(prev).set(selectedTab, newPage));
}
};
// Helper to check if a result is an Application Component (by looking up type name)
const isApplicationComponent = useCallback((result: CMDBSearchResult) => {
const objectType = objectTypeMap.get(result.objectTypeId);
return objectType?.name === APPLICATION_COMPONENT_TYPE_NAME;
}, [objectTypeMap]);
// Handle result click (for Application Components)
const handleResultClick = (result: CMDBSearchResult) => {
if (isApplicationComponent(result)) {
navigate(`/application/${result.id}`);
}
};
// Get avatar URL with Jira host prefix
const getAvatarUrl = (avatarUrl: string) => {
if (!avatarUrl) return null;
if (avatarUrl.startsWith('http')) return avatarUrl;
return `${jiraHost}${avatarUrl}`;
};
return (
<div className="space-y-6">
{/* Header */}
<div className="text-center">
<div className="inline-flex items-center justify-center w-14 h-14 bg-gradient-to-br from-blue-500 to-blue-600 rounded-2xl mb-4 shadow-lg">
<svg className="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<h1 className="text-2xl font-bold text-gray-900 mb-1">CMDB Zoeken</h1>
<p className="text-gray-500 text-sm">
Zoek naar applicaties, servers, infrastructuur en andere items in de CMDB.
</p>
</div>
{/* Search Form */}
<form onSubmit={handleSearch} className="max-w-3xl mx-auto">
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<svg className="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Zoek op naam, key, of beschrijving..."
className="w-full pl-12 pr-28 py-3.5 text-base border-2 border-gray-200 rounded-xl focus:border-blue-500 focus:ring-4 focus:ring-blue-100 transition-all outline-none"
disabled={loading}
/>
<button
type="submit"
disabled={loading || !searchQuery.trim()}
className="absolute inset-y-1.5 right-1.5 px-5 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-medium rounded-lg transition-colors flex items-center gap-2"
>
{loading && (
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
)}
Zoeken
</button>
</div>
</form>
{/* Error Message */}
{error && (
<div className="max-w-3xl mx-auto bg-red-50 border border-red-200 rounded-lg p-4 text-red-700">
<div className="flex items-center gap-2">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{error}
</div>
</div>
)}
{/* Results */}
{hasSearched && searchResults && !loading && (
<div className="space-y-4">
{/* Results Summary */}
<div className="flex items-center justify-between">
<p className="text-sm text-gray-600">
<span className="font-medium">{searchResults.metadata.total}</span> resultaten gevonden
{searchResults.metadata.total !== searchResults.results.length && (
<span className="text-gray-400"> (eerste {searchResults.results.length} getoond)</span>
)}
</p>
</div>
{searchResults.results.length === 0 ? (
<div className="text-center py-12 bg-gray-50 rounded-xl">
<svg className="w-12 h-12 text-gray-300 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p className="text-gray-500">Geen resultaten gevonden voor "{searchQuery}"</p>
<p className="text-gray-400 text-sm mt-1">Probeer een andere zoekterm</p>
</div>
) : (
<>
{/* Object Type Tabs */}
<div className="border-b border-gray-200">
<nav className="-mb-px flex space-x-1 overflow-x-auto pb-px" aria-label="Tabs">
{sortedObjectTypes.map((objectType) => {
const count = resultsByType.get(objectType.id)?.length || 0;
const isActive = selectedTab === objectType.id;
return (
<button
key={objectType.id}
onClick={() => handleTabChange(objectType.id)}
className={`
flex items-center gap-2 whitespace-nowrap py-3 px-4 border-b-2 text-sm font-medium transition-colors
${isActive
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'}
`}
>
{jiraHost && objectType.iconUrl && (
<img
src={getAvatarUrl(objectType.iconUrl) || ''}
alt=""
className="w-4 h-4"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
/>
)}
<span>{objectType.name}</span>
<span className={`
px-2 py-0.5 text-xs rounded-full
${isActive ? 'bg-blue-100 text-blue-600' : 'bg-gray-100 text-gray-600'}
`}>
{count}
</span>
</button>
);
})}
</nav>
</div>
{/* Status Filter */}
{statusOptions.length > 0 && (
<div className="flex items-center gap-3">
<label className="text-sm text-gray-600">Filter op status:</label>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="text-sm border border-gray-300 rounded-lg px-3 py-1.5 focus:border-blue-500 focus:ring-2 focus:ring-blue-100 outline-none"
>
<option value="">Alle statussen ({currentTabResults.length})</option>
{statusOptions.map(status => {
const count = currentTabResults.filter(r => {
const s = getAttributeValue(r, 'Status');
const sName = s && typeof s === 'object' && (s as any).name ? (s as any).name : s;
return sName === status;
}).length;
return (
<option key={status} value={status}>
{status} ({count})
</option>
);
})}
</select>
{statusFilter && (
<button
onClick={() => setStatusFilter('')}
className="text-sm text-blue-600 hover:text-blue-700"
>
Wis filter
</button>
)}
</div>
)}
{/* Results List */}
<div className="space-y-2">
{paginatedResults.map((result) => {
const status = getAttributeValue(result, 'Status');
// Handle status objects with nested structure (null check required because typeof null === 'object')
const statusDisplay = status && typeof status === 'object' && (status as any).name
? (status as any).name
: status;
const statusInfo = getStatusInfo(statusDisplay);
const description = getAttributeValue(result, 'Description');
const isClickable = isApplicationComponent(result);
return (
<div
key={result.id}
onClick={() => isClickable && handleResultClick(result)}
className={`
bg-white border border-gray-200 rounded-lg p-4
${isClickable
? 'cursor-pointer hover:border-blue-300 hover:shadow-sm transition-all'
: ''}
`}
>
<div className="flex items-start gap-3">
{/* Avatar */}
<div className="flex-shrink-0 w-10 h-10 bg-gray-100 rounded-lg flex items-center justify-center overflow-hidden">
{result.avatarUrl && jiraHost ? (
<img
src={getAvatarUrl(result.avatarUrl) || ''}
alt=""
className="w-6 h-6"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
(e.target as HTMLImageElement).parentElement!.innerHTML = `
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6z" />
</svg>
`;
}}
/>
) : (
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6z" />
</svg>
)}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-gray-400 font-mono">{result.key}</span>
{statusDisplay && (
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${statusInfo.bg} ${statusInfo.color}`}>
{statusDisplay}
</span>
)}
{isClickable && (
<span className="text-xs text-blue-500 flex items-center gap-1">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
Klik om te openen
</span>
)}
</div>
<h3 className="font-medium text-gray-900 mt-0.5">{result.label}</h3>
{description && (
<p className="text-sm text-gray-500 mt-1 line-clamp-2">
{stripHtml(description).substring(0, 200)}
{stripHtml(description).length > 200 && '...'}
</p>
)}
</div>
</div>
</div>
);
})}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between pt-4 border-t border-gray-200">
<p className="text-sm text-gray-600">
Pagina {pageForCurrentTab} van {totalPages} ({filteredResults.length} items)
</p>
<div className="flex items-center gap-2">
<button
onClick={() => handlePageChange(pageForCurrentTab - 1)}
disabled={pageForCurrentTab === 1}
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Vorige
</button>
<button
onClick={() => handlePageChange(pageForCurrentTab + 1)}
disabled={pageForCurrentTab === totalPages}
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Volgende
</button>
</div>
</div>
)}
</>
)}
</div>
)}
{/* Quick Links (only show when no search has been performed) */}
{!hasSearched && (
<div className="mt-8 grid grid-cols-1 sm:grid-cols-3 gap-4 max-w-2xl mx-auto">
<a
href="/app-components"
className="flex items-center gap-3 p-4 bg-gray-50 hover:bg-gray-100 rounded-xl transition-colors group"
>
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center group-hover:bg-blue-200 transition-colors">
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
</svg>
</div>
<div>
<p className="font-medium text-gray-900">Application Components</p>
<p className="text-sm text-gray-500">Dashboard & overzicht</p>
</div>
</a>
<a
href="/reports/team-dashboard"
className="flex items-center gap-3 p-4 bg-gray-50 hover:bg-gray-100 rounded-xl transition-colors group"
>
<div className="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center group-hover:bg-green-200 transition-colors">
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
</div>
<div>
<p className="font-medium text-gray-900">Rapporten</p>
<p className="text-sm text-gray-500">Team-indeling & analyses</p>
</div>
</a>
<a
href="/app-components/fte-config"
className="flex items-center gap-3 p-4 bg-gray-50 hover:bg-gray-100 rounded-xl transition-colors group"
>
<div className="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center group-hover:bg-purple-200 transition-colors">
<svg className="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<div>
<p className="font-medium text-gray-900">Configuratie</p>
<p className="text-sm text-gray-500">FTE berekening</p>
</div>
</a>
</div>
)}
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,212 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { calculateEffort } from '../services/api';
import type { ApplicationDetails, EffortCalculationBreakdown, ReferenceValue } from '../types';
export interface EffortCalculationResult {
/** The calculated FTE value (after all factors applied) */
calculatedFte: number | null;
/** The full breakdown of the calculation */
breakdown: EffortCalculationBreakdown | null;
/** Whether a calculation is in progress */
isCalculating: boolean;
/** Any error that occurred during calculation */
error: string | null;
/** Manually trigger a recalculation */
recalculate: () => Promise<void>;
}
export interface EffortCalculationOverrides {
governanceModel?: ReferenceValue | null;
applicationType?: ReferenceValue | null;
businessImpactAnalyse?: ReferenceValue | null;
applicationManagementHosting?: ReferenceValue | null;
dynamicsFactor?: ReferenceValue | null;
complexityFactor?: ReferenceValue | null;
numberOfUsers?: ReferenceValue | null;
hostingType?: ReferenceValue | null;
applicationFunctions?: ReferenceValue[];
}
export interface EffortCalculationInput {
/** Base application data */
application: ApplicationDetails | null;
/** Optional overrides for real-time preview (e.g., when user changes fields) */
overrides?: EffortCalculationOverrides;
/** Whether to auto-calculate on input changes (default: true) */
autoCalculate?: boolean;
/** Debounce delay in milliseconds (default: 150) */
debounceMs?: number;
}
/**
* Build application data with overrides applied.
* Exported for use in components that need manual control.
*/
export function buildApplicationDataWithOverrides(
application: ApplicationDetails,
overrides?: EffortCalculationOverrides
): ApplicationDetails {
if (!overrides) return application;
return {
...application,
governanceModel: overrides.governanceModel !== undefined
? overrides.governanceModel
: application.governanceModel,
applicationType: overrides.applicationType !== undefined
? overrides.applicationType
: application.applicationType,
businessImpactAnalyse: overrides.businessImpactAnalyse !== undefined
? overrides.businessImpactAnalyse
: application.businessImpactAnalyse,
applicationManagementHosting: overrides.applicationManagementHosting !== undefined
? overrides.applicationManagementHosting
: application.applicationManagementHosting,
dynamicsFactor: overrides.dynamicsFactor !== undefined
? overrides.dynamicsFactor
: application.dynamicsFactor,
complexityFactor: overrides.complexityFactor !== undefined
? overrides.complexityFactor
: application.complexityFactor,
numberOfUsers: overrides.numberOfUsers !== undefined
? overrides.numberOfUsers
: application.numberOfUsers,
hostingType: overrides.hostingType !== undefined
? overrides.hostingType
: application.hostingType,
applicationFunctions: overrides.applicationFunctions !== undefined
? overrides.applicationFunctions
: application.applicationFunctions,
};
}
/**
* Custom hook for FTE effort calculation.
* Centralizes the logic for calculating application management effort.
*
* Usage (simple - auto-calculate when application changes):
* ```tsx
* const { calculatedFte, breakdown } = useEffortCalculation({ application });
* ```
*
* Usage (with overrides for form preview):
* ```tsx
* const { calculatedFte, breakdown } = useEffortCalculation({
* application,
* overrides: { governanceModel: selectedGovernanceModel },
* debounceMs: 300,
* });
* ```
*/
export function useEffortCalculation({
application,
overrides,
autoCalculate = true,
debounceMs = 150,
}: EffortCalculationInput): EffortCalculationResult {
const [calculatedFte, setCalculatedFte] = useState<number | null>(null);
const [breakdown, setBreakdown] = useState<EffortCalculationBreakdown | null>(null);
const [isCalculating, setIsCalculating] = useState(false);
const [error, setError] = useState<string | null>(null);
// Track mounted state to prevent state updates after unmount
const isMountedRef = useRef(true);
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);
// Build the application data with any overrides applied
const applicationData = useMemo(() => {
if (!application) return null;
return buildApplicationDataWithOverrides(application, overrides);
}, [application, overrides]);
// Perform the calculation
const performCalculation = useCallback(async () => {
if (!applicationData) {
setCalculatedFte(null);
setBreakdown(null);
return;
}
setIsCalculating(true);
setError(null);
try {
const result = await calculateEffort(applicationData);
if (isMountedRef.current) {
setCalculatedFte(result.requiredEffortApplicationManagement);
setBreakdown(result.breakdown);
}
} catch (err) {
if (isMountedRef.current) {
console.error('Failed to calculate effort:', err);
setError(err instanceof Error ? err.message : 'Calculation failed');
setCalculatedFte(null);
setBreakdown(null);
}
} finally {
if (isMountedRef.current) {
setIsCalculating(false);
}
}
}, [applicationData]);
// Auto-calculate when dependencies change
useEffect(() => {
if (!autoCalculate) return;
if (!application) {
setCalculatedFte(null);
setBreakdown(null);
return;
}
// Debounce to prevent excessive API calls
const timeoutId = setTimeout(() => {
performCalculation();
}, debounceMs);
return () => clearTimeout(timeoutId);
}, [autoCalculate, application, applicationData, debounceMs, performCalculation]);
// Reset when application ID changes
useEffect(() => {
setCalculatedFte(null);
setBreakdown(null);
setError(null);
}, [application?.id]);
return {
calculatedFte,
breakdown,
isCalculating,
error,
recalculate: performCalculation,
};
}
/**
* Get the effective FTE value considering override
*/
export function getEffectiveFte(
calculatedFte: number | null,
overrideFte: number | null | undefined,
fallbackFte: number | null | undefined
): number | null {
// If override is set, use it
if (overrideFte !== null && overrideFte !== undefined) {
return overrideFte;
}
// Otherwise use calculated value
if (calculatedFte !== null) {
return calculatedFte;
}
// Fallback to stored value
return fallbackFte ?? null;
}

View File

@@ -14,6 +14,48 @@ import type {
const API_BASE = '/api'; const API_BASE = '/api';
// =============================================================================
// Error Types
// =============================================================================
export interface ConflictError {
status: 'conflict';
message: string;
conflicts?: Array<{
field: string;
fieldName: string;
proposedValue: unknown;
jiraValue: unknown;
}>;
jiraUpdatedAt?: string;
canMerge?: boolean;
warning?: string;
actions: {
forceOverwrite: boolean;
merge: boolean;
discard: boolean;
};
}
export class ApiError extends Error {
constructor(
message: string,
public status: number,
public data?: unknown
) {
super(message);
this.name = 'ApiError';
}
isConflict(): this is ApiError & { data: ConflictError } {
return this.status === 409;
}
}
// =============================================================================
// Base Fetch
// =============================================================================
async function fetchApi<T>( async function fetchApi<T>(
endpoint: string, endpoint: string,
options: RequestInit = {} options: RequestInit = {}
@@ -27,14 +69,21 @@ async function fetchApi<T>(
}); });
if (!response.ok) { if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' })); const errorData = await response.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(error.error || error.message || 'API request failed'); throw new ApiError(
errorData.error || errorData.message || 'API request failed',
response.status,
errorData
);
} }
return response.json(); return response.json();
} }
// =============================================================================
// Applications // Applications
// =============================================================================
export async function searchApplications( export async function searchApplications(
filters: SearchFilters, filters: SearchFilters,
page: number = 1, page: number = 1,
@@ -50,6 +99,49 @@ export async function getApplicationById(id: string): Promise<ApplicationDetails
return fetchApi<ApplicationDetails>(`/applications/${id}`); return fetchApi<ApplicationDetails>(`/applications/${id}`);
} }
/**
* Get application for editing (force refresh from Jira)
* Returns fresh data with _jiraUpdatedAt for conflict detection
*/
export async function getApplicationForEdit(id: string): Promise<ApplicationDetails> {
return fetchApi<ApplicationDetails>(`/applications/${id}?mode=edit`);
}
// Related objects response type
export interface RelatedObject {
id: number;
key: string;
name: string;
label: string;
attributes: Record<string, string | null>;
}
export interface RelatedObjectsResponse {
objects: RelatedObject[];
total: number;
}
export async function getRelatedObjects(
applicationId: string,
objectType: string,
attributes?: string[]
): Promise<RelatedObjectsResponse> {
const params = attributes && attributes.length > 0
? `?attributes=${encodeURIComponent(attributes.join(','))}`
: '';
return fetchApi<RelatedObjectsResponse>(`/applications/${applicationId}/related/${objectType}${params}`);
}
export interface UpdateApplicationOptions {
/** The _jiraUpdatedAt from when the application was loaded for editing */
originalUpdatedAt?: string;
}
/**
* Update application with optional conflict detection
*
* @throws {ApiError} with status 409 if there's a conflict
*/
export async function updateApplication( export async function updateApplication(
id: string, id: string,
updates: { updates: {
@@ -58,7 +150,41 @@ export async function updateApplication(
complexityFactor?: ReferenceValue; complexityFactor?: ReferenceValue;
numberOfUsers?: ReferenceValue; numberOfUsers?: ReferenceValue;
governanceModel?: ReferenceValue; governanceModel?: ReferenceValue;
applicationCluster?: ReferenceValue; applicationSubteam?: ReferenceValue;
applicationTeam?: ReferenceValue;
applicationType?: ReferenceValue;
hostingType?: ReferenceValue;
businessImpactAnalyse?: ReferenceValue;
applicationManagementHosting?: string;
applicationManagementTAM?: string;
overrideFTE?: number | null;
source?: 'AI_ACCEPTED' | 'AI_MODIFIED' | 'MANUAL';
},
options?: UpdateApplicationOptions
): Promise<ApplicationDetails> {
const body = options?.originalUpdatedAt
? { updates, _jiraUpdatedAt: options.originalUpdatedAt }
: updates;
return fetchApi<ApplicationDetails>(`/applications/${id}`, {
method: 'PUT',
body: JSON.stringify(body),
});
}
/**
* Force update application (ignore conflicts)
*/
export async function forceUpdateApplication(
id: string,
updates: {
applicationFunctions?: ReferenceValue[];
dynamicsFactor?: ReferenceValue;
complexityFactor?: ReferenceValue;
numberOfUsers?: ReferenceValue;
governanceModel?: ReferenceValue;
applicationSubteam?: ReferenceValue;
applicationTeam?: ReferenceValue;
applicationType?: ReferenceValue; applicationType?: ReferenceValue;
hostingType?: ReferenceValue; hostingType?: ReferenceValue;
businessImpactAnalyse?: ReferenceValue; businessImpactAnalyse?: ReferenceValue;
@@ -68,7 +194,7 @@ export async function updateApplication(
source?: 'AI_ACCEPTED' | 'AI_MODIFIED' | 'MANUAL'; source?: 'AI_ACCEPTED' | 'AI_MODIFIED' | 'MANUAL';
} }
): Promise<ApplicationDetails> { ): Promise<ApplicationDetails> {
return fetchApi<ApplicationDetails>(`/applications/${id}`, { return fetchApi<ApplicationDetails>(`/applications/${id}/force`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify(updates), body: JSON.stringify(updates),
}); });
@@ -94,7 +220,55 @@ export async function getApplicationHistory(id: string): Promise<ClassificationR
return fetchApi<ClassificationResult[]>(`/applications/${id}/history`); return fetchApi<ClassificationResult[]>(`/applications/${id}/history`);
} }
// =============================================================================
// Cache Management
// =============================================================================
export interface CacheStatus {
cache: {
totalObjects: number;
objectsByType: Record<string, number>;
totalRelations: number;
lastFullSync: string | null;
lastIncrementalSync: string | null;
isWarm: boolean;
dbSizeBytes: number;
};
sync: {
isRunning: boolean;
isSyncing: boolean;
lastFullSync: string | null;
lastIncrementalSync: string | null;
nextIncrementalSync: string | null;
incrementalInterval: number;
};
supportedTypes: string[];
}
export async function getCacheStatus(): Promise<CacheStatus> {
return fetchApi<CacheStatus>('/cache/status');
}
export async function triggerSync(): Promise<{ status: string; message: string }> {
return fetchApi<{ status: string; message: string }>('/cache/sync', {
method: 'POST',
});
}
export async function triggerTypeSync(objectType: string): Promise<{
status: string;
objectType: string;
stats: { objectsProcessed: number; relationsExtracted: number; duration: number };
}> {
return fetchApi(`/cache/sync/${objectType}`, {
method: 'POST',
});
}
// =============================================================================
// AI Provider type // AI Provider type
// =============================================================================
export type AIProvider = 'claude' | 'openai'; export type AIProvider = 'claude' | 'openai';
// AI Status response type // AI Status response type
@@ -112,7 +286,10 @@ export interface AIStatusResponse {
}; };
} }
// =============================================================================
// Classifications // Classifications
// =============================================================================
export async function getAISuggestion(id: string, provider?: AIProvider): Promise<AISuggestion> { export async function getAISuggestion(id: string, provider?: AIProvider): Promise<AISuggestion> {
const url = provider const url = provider
? `/classifications/suggest/${id}?provider=${provider}` ? `/classifications/suggest/${id}?provider=${provider}`
@@ -144,7 +321,10 @@ export async function getAIPrompt(id: string): Promise<{ prompt: string }> {
return fetchApi(`/classifications/prompt/${id}`); return fetchApi(`/classifications/prompt/${id}`);
} }
// =============================================================================
// Reference Data // Reference Data
// =============================================================================
export async function getReferenceData(): Promise<{ export async function getReferenceData(): Promise<{
dynamicsFactors: ReferenceValue[]; dynamicsFactors: ReferenceValue[];
complexityFactors: ReferenceValue[]; complexityFactors: ReferenceValue[];
@@ -153,12 +333,14 @@ export async function getReferenceData(): Promise<{
organisations: ReferenceValue[]; organisations: ReferenceValue[];
hostingTypes: ReferenceValue[]; hostingTypes: ReferenceValue[];
applicationFunctions: ReferenceValue[]; applicationFunctions: ReferenceValue[];
applicationClusters: ReferenceValue[]; applicationSubteams: ReferenceValue[];
applicationTeams: ReferenceValue[];
applicationTypes: ReferenceValue[]; applicationTypes: ReferenceValue[];
businessImportance: ReferenceValue[]; businessImportance: ReferenceValue[];
businessImpactAnalyses: ReferenceValue[]; businessImpactAnalyses: ReferenceValue[];
applicationManagementHosting: ReferenceValue[]; applicationManagementHosting: ReferenceValue[];
applicationManagementTAM: ReferenceValue[]; applicationManagementTAM: ReferenceValue[];
subteamToTeamMapping: Record<string, ReferenceValue | null>;
}> { }> {
return fetchApi('/reference-data'); return fetchApi('/reference-data');
} }
@@ -191,8 +373,8 @@ export async function getHostingTypes(): Promise<ReferenceValue[]> {
return fetchApi<ReferenceValue[]>('/reference-data/hosting-types'); return fetchApi<ReferenceValue[]>('/reference-data/hosting-types');
} }
export async function getApplicationClusters(): Promise<ReferenceValue[]> { export async function getApplicationSubteams(): Promise<ReferenceValue[]> {
return fetchApi<ReferenceValue[]>('/reference-data/application-clusters'); return fetchApi<ReferenceValue[]>('/reference-data/application-subteams');
} }
export async function getApplicationTypes(): Promise<ReferenceValue[]> { export async function getApplicationTypes(): Promise<ReferenceValue[]> {
@@ -211,12 +393,18 @@ export async function getBusinessImpactAnalyses(): Promise<ReferenceValue[]> {
return fetchApi<ReferenceValue[]>('/reference-data/business-impact-analyses'); return fetchApi<ReferenceValue[]>('/reference-data/business-impact-analyses');
} }
// =============================================================================
// Config // Config
// =============================================================================
export async function getConfig(): Promise<{ jiraHost: string }> { export async function getConfig(): Promise<{ jiraHost: string }> {
return fetchApi<{ jiraHost: string }>('/config'); return fetchApi<{ jiraHost: string }>('/config');
} }
// =============================================================================
// Dashboard // Dashboard
// =============================================================================
export async function getDashboardStats(forceRefresh: boolean = false): Promise<DashboardStats> { export async function getDashboardStats(forceRefresh: boolean = false): Promise<DashboardStats> {
const params = forceRefresh ? '?refresh=true' : ''; const params = forceRefresh ? '?refresh=true' : '';
return fetchApi<DashboardStats>(`/dashboard/stats${params}`); return fetchApi<DashboardStats>(`/dashboard/stats${params}`);
@@ -226,7 +414,10 @@ export async function getRecentClassifications(limit: number = 10): Promise<Clas
return fetchApi<ClassificationResult[]>(`/dashboard/recent?limit=${limit}`); return fetchApi<ClassificationResult[]>(`/dashboard/recent?limit=${limit}`);
} }
// =============================================================================
// Team Dashboard // Team Dashboard
// =============================================================================
export async function getTeamDashboardData(excludedStatuses: ApplicationStatus[] = []): Promise<TeamDashboardData> { export async function getTeamDashboardData(excludedStatuses: ApplicationStatus[] = []): Promise<TeamDashboardData> {
const params = new URLSearchParams(); const params = new URLSearchParams();
// Always send excludedStatuses parameter, even if empty, so backend knows the user's intent // Always send excludedStatuses parameter, even if empty, so backend knows the user's intent
@@ -235,7 +426,10 @@ export async function getTeamDashboardData(excludedStatuses: ApplicationStatus[]
return fetchApi<TeamDashboardData>(`/applications/team-dashboard?${queryString}`); return fetchApi<TeamDashboardData>(`/applications/team-dashboard?${queryString}`);
} }
// =============================================================================
// Configuration // Configuration
// =============================================================================
export interface EffortCalculationConfig { export interface EffortCalculationConfig {
governanceModelRules: Array<{ governanceModelRules: Array<{
governanceModel: string; governanceModel: string;
@@ -365,7 +559,10 @@ export async function updateEffortCalculationConfigV25(config: EffortCalculation
}); });
} }
// =============================================================================
// AI Chat // AI Chat
// =============================================================================
import type { ChatMessage, ChatResponse } from '../types'; import type { ChatMessage, ChatResponse } from '../types';
export async function sendChatMessage( export async function sendChatMessage(
@@ -389,3 +586,98 @@ export async function clearConversation(conversationId: string): Promise<{ succe
method: 'DELETE', method: 'DELETE',
}); });
} }
// =============================================================================
// CMDB Search
// =============================================================================
export interface CMDBSearchObjectType {
id: number;
name: string;
iconUrl: string;
}
export interface CMDBSearchResultAttribute {
id: number;
name: string;
objectTypeAttributeId: number;
values: string[];
}
export interface CMDBSearchResult {
id: number;
key: string;
label: string;
objectTypeId: number;
avatarUrl: string;
attributes: CMDBSearchResultAttribute[];
}
export interface CMDBSearchResponse {
metadata: {
count: number;
offset: number;
limit: number;
total: number;
criteria: { query: string; type: string; schema: number };
};
objectTypes: CMDBSearchObjectType[];
results: CMDBSearchResult[];
}
// CMDB free-text search
export async function searchCMDB(query: string, limit: number = 10000): Promise<CMDBSearchResponse> {
return fetchApi<CMDBSearchResponse>(`/search?query=${encodeURIComponent(query)}&limit=${limit}`);
}
// =============================================================================
// Schema / Data Model
// =============================================================================
export interface SchemaAttributeDefinition {
jiraId: number;
name: string;
fieldName: string;
type: 'text' | 'integer' | 'float' | 'boolean' | 'date' | 'datetime' | 'select' | 'reference' | 'url' | 'email' | 'textarea' | 'user' | 'status' | 'unknown';
isMultiple: boolean;
isEditable: boolean;
isRequired: boolean;
isSystem: boolean;
referenceTypeId?: number;
referenceTypeName?: string;
description?: string;
}
export interface SchemaObjectTypeDefinition {
jiraTypeId: number;
name: string;
typeName: string;
syncPriority: number;
objectCount: number;
attributes: SchemaAttributeDefinition[];
incomingLinks: Array<{
fromType: string;
fromTypeName: string;
attributeName: string;
isMultiple: boolean;
}>;
outgoingLinks: Array<{
toType: string;
toTypeName: string;
attributeName: string;
isMultiple: boolean;
}>;
}
export interface SchemaResponse {
metadata: {
generatedAt: string;
objectTypeCount: number;
totalAttributes: number;
};
objectTypes: Record<string, SchemaObjectTypeDefinition>;
}
export async function getSchema(): Promise<SchemaResponse> {
return fetchApi<SchemaResponse>('/schema');
}

View File

@@ -9,6 +9,9 @@ export interface User {
} }
interface AuthConfig { interface AuthConfig {
// The configured authentication method
authMethod: 'pat' | 'oauth' | 'none';
// Legacy fields (for backward compatibility)
oauthEnabled: boolean; oauthEnabled: boolean;
serviceAccountEnabled: boolean; serviceAccountEnabled: boolean;
jiraHost: string; jiraHost: string;

View File

@@ -11,7 +11,7 @@ interface SearchState {
setGovernanceModel: (value: 'all' | 'filled' | 'empty') => void; setGovernanceModel: (value: 'all' | 'filled' | 'empty') => void;
setDynamicsFactor: (value: 'all' | 'filled' | 'empty') => void; setDynamicsFactor: (value: 'all' | 'filled' | 'empty') => void;
setComplexityFactor: (value: 'all' | 'filled' | 'empty') => void; setComplexityFactor: (value: 'all' | 'filled' | 'empty') => void;
setApplicationCluster: (value: 'all' | 'filled' | 'empty') => void; setApplicationSubteam: (value: 'all' | 'filled' | 'empty' | string) => void;
setApplicationType: (value: 'all' | 'filled' | 'empty') => void; setApplicationType: (value: 'all' | 'filled' | 'empty') => void;
setOrganisation: (value: string | undefined) => void; setOrganisation: (value: string | undefined) => void;
setHostingType: (value: string | undefined) => void; setHostingType: (value: string | undefined) => void;
@@ -40,7 +40,7 @@ const defaultFilters: SearchFilters = {
governanceModel: 'all', governanceModel: 'all',
dynamicsFactor: 'all', dynamicsFactor: 'all',
complexityFactor: 'all', complexityFactor: 'all',
applicationCluster: 'all', applicationSubteam: 'all',
applicationType: 'all', applicationType: 'all',
organisation: undefined, organisation: undefined,
hostingType: undefined, hostingType: undefined,
@@ -88,9 +88,9 @@ export const useSearchStore = create<SearchState>((set) => ({
currentPage: 1, currentPage: 1,
})), })),
setApplicationCluster: (value) => setApplicationSubteam: (value) =>
set((state) => ({ set((state) => ({
filters: { ...state.filters, applicationCluster: value }, filters: { ...state.filters, applicationSubteam: value },
currentPage: 1, currentPage: 1,
})), })),

View File

@@ -26,6 +26,7 @@ export interface ReferenceValue {
remarks?: string; // Remarks attribute for Governance Model remarks?: string; // Remarks attribute for Governance Model
application?: string; // Application attribute for Governance Model application?: string; // Application attribute for Governance Model
indicators?: string; // Indicators attribute for Business Impact Analyse indicators?: string; // Indicators attribute for Business Impact Analyse
teamType?: string; // Type attribute for Team objects (Business, Enabling, Staf)
} }
// Application list item (summary view) // Application list item (summary view)
@@ -38,7 +39,8 @@ export interface ApplicationListItem {
governanceModel: ReferenceValue | null; governanceModel: ReferenceValue | null;
dynamicsFactor: ReferenceValue | null; dynamicsFactor: ReferenceValue | null;
complexityFactor: ReferenceValue | null; complexityFactor: ReferenceValue | null;
applicationCluster: ReferenceValue | null; applicationSubteam: ReferenceValue | null;
applicationTeam: ReferenceValue | null;
applicationType: ReferenceValue | null; applicationType: ReferenceValue | null;
platform: ReferenceValue | null; // Reference to parent Platform Application Component platform: ReferenceValue | null; // Reference to parent Platform Application Component
requiredEffortApplicationManagement: number | null; // Calculated field requiredEffortApplicationManagement: number | null; // Calculated field
@@ -74,7 +76,8 @@ export interface ApplicationDetails {
complexityFactor: ReferenceValue | null; complexityFactor: ReferenceValue | null;
numberOfUsers: ReferenceValue | null; numberOfUsers: ReferenceValue | null;
governanceModel: ReferenceValue | null; governanceModel: ReferenceValue | null;
applicationCluster: ReferenceValue | null; applicationSubteam: ReferenceValue | null;
applicationTeam: ReferenceValue | null;
applicationType: ReferenceValue | null; applicationType: ReferenceValue | null;
platform: ReferenceValue | null; // Reference to parent Platform Application Component platform: ReferenceValue | null; // Reference to parent Platform Application Component
requiredEffortApplicationManagement: number | null; // Calculated field requiredEffortApplicationManagement: number | null; // Calculated field
@@ -92,7 +95,7 @@ export interface SearchFilters {
governanceModel?: 'all' | 'filled' | 'empty'; governanceModel?: 'all' | 'filled' | 'empty';
dynamicsFactor?: 'all' | 'filled' | 'empty'; dynamicsFactor?: 'all' | 'filled' | 'empty';
complexityFactor?: 'all' | 'filled' | 'empty'; complexityFactor?: 'all' | 'filled' | 'empty';
applicationCluster?: 'all' | 'filled' | 'empty'; applicationSubteam?: 'all' | 'filled' | 'empty' | string; // Can be 'all', 'empty', or a specific subteam name
applicationType?: 'all' | 'filled' | 'empty'; applicationType?: 'all' | 'filled' | 'empty';
organisation?: string; organisation?: string;
hostingType?: string; hostingType?: string;
@@ -168,7 +171,8 @@ export interface PendingChanges {
complexityFactor?: { from: ReferenceValue | null; to: ReferenceValue }; complexityFactor?: { from: ReferenceValue | null; to: ReferenceValue };
numberOfUsers?: { from: ReferenceValue | null; to: ReferenceValue }; numberOfUsers?: { from: ReferenceValue | null; to: ReferenceValue };
governanceModel?: { from: ReferenceValue | null; to: ReferenceValue }; governanceModel?: { from: ReferenceValue | null; to: ReferenceValue };
applicationCluster?: { from: ReferenceValue | null; to: ReferenceValue }; applicationSubteam?: { from: ReferenceValue | null; to: ReferenceValue };
applicationTeam?: { from: ReferenceValue | null; to: ReferenceValue };
applicationType?: { from: ReferenceValue | null; to: ReferenceValue }; applicationType?: { from: ReferenceValue | null; to: ReferenceValue };
} }
@@ -189,7 +193,8 @@ export interface ReferenceOptions {
numberOfUsers: ReferenceValue[]; numberOfUsers: ReferenceValue[];
governanceModels: ReferenceValue[]; governanceModels: ReferenceValue[];
applicationFunctions: ReferenceValue[]; applicationFunctions: ReferenceValue[];
applicationClusters: ReferenceValue[]; applicationSubteams: ReferenceValue[];
applicationTeams: ReferenceValue[];
applicationTypes: ReferenceValue[]; applicationTypes: ReferenceValue[];
organisations: ReferenceValue[]; organisations: ReferenceValue[];
hostingTypes: ReferenceValue[]; hostingTypes: ReferenceValue[];
@@ -220,9 +225,12 @@ export interface ZiraTaxonomy {
// Dashboard statistics // Dashboard statistics
export interface DashboardStats { export interface DashboardStats {
totalApplications: number; totalApplications: number; // Excluding Closed/Deprecated
totalAllApplications: number; // Including all statuses (for status distribution)
classifiedCount: number; classifiedCount: number;
unclassifiedCount: number; unclassifiedCount: number;
withApplicationFunction: number;
applicationFunctionPercentage: number;
byStatus: Record<string, number>; byStatus: Record<string, number>;
byDomain: Record<string, number>; byDomain: Record<string, number>;
byGovernanceModel: Record<string, number>; byGovernanceModel: Record<string, number>;
@@ -284,8 +292,9 @@ export interface PlatformWithWorkloads {
totalEffort: number; // platformEffort + workloadsEffort totalEffort: number; // platformEffort + workloadsEffort
} }
export interface TeamDashboardCluster { // Subteam level in team dashboard hierarchy
cluster: ReferenceValue | null; export interface TeamDashboardSubteam {
subteam: ReferenceValue | null;
applications: ApplicationListItem[]; // Regular applications (non-Platform, non-Workload) applications: ApplicationListItem[]; // Regular applications (non-Platform, non-Workload)
platforms: PlatformWithWorkloads[]; // Platforms with their workloads platforms: PlatformWithWorkloads[]; // Platforms with their workloads
totalEffort: number; // Sum of all applications + platforms + workloads totalEffort: number; // Sum of all applications + platforms + workloads
@@ -295,17 +304,21 @@ export interface TeamDashboardCluster {
byGovernanceModel: Record<string, number>; // Distribution per governance model byGovernanceModel: Record<string, number>; // Distribution per governance model
} }
// Team level in team dashboard hierarchy (contains subteams)
export interface TeamDashboardTeam {
team: ReferenceValue | null; // team.teamType contains "Business", "Enabling", or "Staf"
subteams: TeamDashboardSubteam[];
// Aggregated KPIs (sum of all subteams)
totalEffort: number;
minEffort: number;
maxEffort: number;
applicationCount: number;
byGovernanceModel: Record<string, number>;
}
export interface TeamDashboardData { export interface TeamDashboardData {
clusters: TeamDashboardCluster[]; teams: TeamDashboardTeam[];
unassigned: { unassigned: TeamDashboardSubteam; // Apps without team assignment
applications: ApplicationListItem[]; // Regular applications (non-Platform, non-Workload)
platforms: PlatformWithWorkloads[]; // Platforms with their workloads
totalEffort: number; // Sum of all applications + platforms + workloads
minEffort: number; // Sum of all minimum FTE values
maxEffort: number; // Sum of all maximum FTE values
applicationCount: number; // Count of all applications (including platforms and workloads)
byGovernanceModel: Record<string, number>; // Distribution per governance model
};
} }
// Chat message for AI conversation // Chat message for AI conversation

View File

@@ -12,7 +12,8 @@
"dev:backend": "npm run dev --workspace=backend", "dev:backend": "npm run dev --workspace=backend",
"dev:frontend": "npm run dev --workspace=frontend", "dev:frontend": "npm run dev --workspace=frontend",
"build": "npm run build --workspaces", "build": "npm run build --workspaces",
"start": "npm run start --workspace=backend" "start": "npm run start --workspace=backend",
"generate-schema": "npm run generate-schema --workspace=backend"
}, },
"devDependencies": { "devDependencies": {
"concurrently": "^8.2.2" "concurrently": "^8.2.2"