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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user