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

@@ -1,9 +1,11 @@
import { Router, Request, Response } from 'express';
import { dataService } from '../services/dataService.js';
import { databaseService } from '../services/database.js';
import { cmdbService } from '../services/cmdbService.js';
import { logger } from '../services/logger.js';
import { calculateRequiredEffortApplicationManagement, calculateRequiredEffortApplicationManagementWithBreakdown } from '../services/effortCalculation.js';
import type { SearchFilters, ApplicationUpdateRequest, ReferenceValue, ClassificationResult, ApplicationDetails, ApplicationStatus } from '../types/index.js';
import { calculateRequiredEffortApplicationManagementWithBreakdown } from '../services/effortCalculation.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();
@@ -50,9 +52,12 @@ router.get('/team-dashboard', async (req: Request, res: Response) => {
});
// 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) => {
try {
const { id } = req.params;
const mode = req.query.mode as string | undefined;
// Don't treat special routes as application IDs
if (id === 'team-dashboard' || id === 'calculate-effort' || id === 'search') {
@@ -60,7 +65,10 @@ router.get('/:id', async (req: Request, res: Response) => {
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) {
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) => {
try {
const { id } = req.params;
const updates = req.body as {
applicationFunctions?: ReferenceValue[];
dynamicsFactor?: ReferenceValue;
complexityFactor?: ReferenceValue;
numberOfUsers?: ReferenceValue;
governanceModel?: ReferenceValue;
source?: 'AI_ACCEPTED' | 'AI_MODIFIED' | 'MANUAL';
const { updates, _jiraUpdatedAt } = req.body as {
updates?: {
applicationFunctions?: ReferenceValue[];
dynamicsFactor?: ReferenceValue;
complexityFactor?: ReferenceValue;
numberOfUsers?: ReferenceValue;
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);
if (!application) {
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
const changes: ClassificationResult['changes'] = {};
if (updates.applicationFunctions) {
if (actualUpdates.applicationFunctions) {
changes.applicationFunctions = {
from: application.applicationFunctions,
to: updates.applicationFunctions,
to: actualUpdates.applicationFunctions,
};
}
if (updates.dynamicsFactor) {
if (actualUpdates.dynamicsFactor) {
changes.dynamicsFactor = {
from: application.dynamicsFactor,
to: updates.dynamicsFactor,
to: actualUpdates.dynamicsFactor,
};
}
if (updates.complexityFactor) {
if (actualUpdates.complexityFactor) {
changes.complexityFactor = {
from: application.complexityFactor,
to: updates.complexityFactor,
to: actualUpdates.complexityFactor,
};
}
if (updates.numberOfUsers) {
if (actualUpdates.numberOfUsers) {
changes.numberOfUsers = {
from: application.numberOfUsers,
to: updates.numberOfUsers,
to: actualUpdates.numberOfUsers,
};
}
if (updates.governanceModel) {
if (actualUpdates.governanceModel) {
changes.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) {
// Save to classification history
const classificationResult: ClassificationResult = {
applicationId: id,
applicationName: application.name,
changes,
source: updates.source || 'MANUAL',
timestamp: new Date(),
};
databaseService.saveClassificationResult(classificationResult);
const updatedApp = await dataService.getApplicationById(id);
res.json(updatedApp);
} else {
res.status(500).json({ error: 'Failed to update application' });
// Check for conflicts
if (!result.success && result.conflict) {
// Return 409 Conflict with details
res.status(409).json({
status: 'conflict',
message: 'Object is gewijzigd door iemand anders',
conflicts: result.conflict.conflicts,
jiraUpdatedAt: result.conflict.jiraUpdatedAt,
canMerge: result.conflict.canMerge,
warning: result.conflict.warning,
actions: {
forceOverwrite: true,
merge: result.conflict.canMerge || false,
discard: true,
},
});
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) {
logger.error('Failed to update application', error);
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)
router.post('/calculate-effort', async (req: Request, res: Response) => {
try {
@@ -180,7 +252,8 @@ router.post('/calculate-effort', async (req: Request, res: Response) => {
complexityFactor: applicationData.complexityFactor || null,
numberOfUsers: applicationData.numberOfUsers || null,
governanceModel: applicationData.governanceModel || null,
applicationCluster: applicationData.applicationCluster || null,
applicationSubteam: applicationData.applicationSubteam || null,
applicationTeam: applicationData.applicationTeam || null,
applicationType: applicationData.applicationType || null,
platform: applicationData.platform || 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;