diff --git a/backend/src/services/dataService.ts b/backend/src/services/dataService.ts index b4428f8..25e26ca 100644 --- a/backend/src/services/dataService.ts +++ b/backend/src/services/dataService.ts @@ -1283,8 +1283,342 @@ export const dataService = { }, async getTeamDashboardData(excludedStatuses: ApplicationStatus[] = []): Promise { - // Always get from Jira Assets API (has proper Team/Subteam field parsing) - return jiraAssetsService.getTeamDashboardData(excludedStatuses); + // Load from database cache instead of API for better performance + logger.info(`Loading team dashboard data from database cache (excluding: ${excludedStatuses.join(', ')})`); + + try { + // Get all ApplicationComponents from database cache + const allApplications = await cmdbService.getObjects('ApplicationComponent'); + logger.info(`Loaded ${allApplications.length} applications from database cache`); + + // Convert to ApplicationListItem + const applicationListItems = await Promise.all( + allApplications.map(app => toApplicationListItem(app)) + ); + + // Filter out excluded statuses + const filteredApplications = excludedStatuses.length > 0 + ? applicationListItems.filter(app => !app.status || !excludedStatuses.includes(app.status)) + : applicationListItems; + + logger.info(`After status filter: ${filteredApplications.length} applications (excluded: ${excludedStatuses.join(', ')})`); + + // Separate into Platforms, Workloads, and regular applications + const platforms: ApplicationListItem[] = []; + const workloads: ApplicationListItem[] = []; + const regularApplications: ApplicationListItem[] = []; + + for (const app of filteredApplications) { + const isPlatform = app.applicationType?.name === 'Platform'; + const isWorkload = app.platform !== null; + + if (isPlatform) { + platforms.push(app); + } else if (isWorkload) { + workloads.push(app); + } else { + regularApplications.push(app); + } + } + + logger.info(`Identified ${platforms.length} platforms, ${workloads.length} workloads, ${regularApplications.length} regular applications`); + + // Group workloads by their platform + const workloadsByPlatform = new Map(); + for (const workload of workloads) { + const platformId = workload.platform!.objectId; + if (!workloadsByPlatform.has(platformId)) { + workloadsByPlatform.set(platformId, []); + } + workloadsByPlatform.get(platformId)!.push(workload); + } + + // Helper functions for FTE calculations + const getEffectiveFTE = (app: ApplicationListItem): number => { + return app.overrideFTE !== null && app.overrideFTE !== undefined + ? app.overrideFTE + : (app.requiredEffortApplicationManagement || 0); + }; + + const getMinFTE = (app: ApplicationListItem): number => { + if (app.overrideFTE !== null && app.overrideFTE !== undefined) { + return app.overrideFTE; + } + return app.minFTE ?? app.requiredEffortApplicationManagement ?? 0; + }; + + const getMaxFTE = (app: ApplicationListItem): number => { + if (app.overrideFTE !== null && app.overrideFTE !== undefined) { + return app.overrideFTE; + } + return app.maxFTE ?? app.requiredEffortApplicationManagement ?? 0; + }; + + // Build PlatformWithWorkloads structures + const platformsWithWorkloads: PlatformWithWorkloads[] = []; + for (const platform of platforms) { + const platformWorkloads = workloadsByPlatform.get(platform.id) || []; + const platformEffort = getEffectiveFTE(platform); + const workloadsEffort = platformWorkloads.reduce((sum, w) => sum + getEffectiveFTE(w), 0); + + platformsWithWorkloads.push({ + platform, + workloads: platformWorkloads, + platformEffort, + workloadsEffort, + totalEffort: platformEffort + workloadsEffort, + }); + } + + // Helper function to calculate subteam KPIs + const calculateSubteamKPIs = ( + regularApps: ApplicationListItem[], + platformsList: PlatformWithWorkloads[] + ) => { + const regularEffort = regularApps.reduce((sum, app) => sum + getEffectiveFTE(app), 0); + const platformsEffort = platformsList.reduce((sum, p) => sum + p.totalEffort, 0); + const totalEffort = regularEffort + platformsEffort; + + const regularMinEffort = regularApps.reduce((sum, app) => sum + getMinFTE(app), 0); + const regularMaxEffort = regularApps.reduce((sum, app) => sum + getMaxFTE(app), 0); + const platformsMinEffort = platformsList.reduce((sum, p) => { + const platformMin = getMinFTE(p.platform); + const workloadsMin = p.workloads.reduce((s, w) => s + getMinFTE(w), 0); + return sum + platformMin + workloadsMin; + }, 0); + const platformsMaxEffort = platformsList.reduce((sum, p) => { + const platformMax = getMaxFTE(p.platform); + const workloadsMax = p.workloads.reduce((s, w) => s + getMaxFTE(w), 0); + return sum + platformMax + workloadsMax; + }, 0); + const minEffort = regularMinEffort + platformsMinEffort; + const maxEffort = regularMaxEffort + platformsMaxEffort; + + const platformsCount = platformsList.length; + const workloadsCount = platformsList.reduce((sum, p) => sum + p.workloads.length, 0); + const applicationCount = regularApps.length + platformsCount + workloadsCount; + + const byGovernanceModel: Record = {}; + for (const app of regularApps) { + const govModel = app.governanceModel?.name || 'Niet ingesteld'; + byGovernanceModel[govModel] = (byGovernanceModel[govModel] || 0) + 1; + } + for (const pwl of platformsList) { + const govModel = pwl.platform.governanceModel?.name || 'Niet ingesteld'; + byGovernanceModel[govModel] = (byGovernanceModel[govModel] || 0) + 1; + for (const workload of pwl.workloads) { + const workloadGovModel = workload.governanceModel?.name || 'Niet ingesteld'; + byGovernanceModel[workloadGovModel] = (byGovernanceModel[workloadGovModel] || 0) + 1; + } + } + + return { totalEffort, minEffort, maxEffort, applicationCount, byGovernanceModel }; + }; + + // HIERARCHICAL GROUPING: Team -> Subteam -> Applications + type SubteamData = { + subteam: ReferenceValue | null; + regular: ApplicationListItem[]; + platforms: PlatformWithWorkloads[]; + }; + + type TeamData = { + team: ReferenceValue | null; + subteams: Map; + }; + + // Load Subteam -> Team mapping (still from API but cached, so should be fast) + const subteamToTeamMap = await jiraAssetsService.getSubteamToTeamMapping(); + logger.info(`Loaded ${subteamToTeamMap.size} subteam->team mappings`); + + const teamMap = new Map(); + const unassignedData: SubteamData = { + subteam: null, + regular: [], + platforms: [], + }; + + // Helper to get Team from Subteam + const getTeamForSubteam = (subteam: ReferenceValue | null): ReferenceValue | null => { + if (!subteam) return null; + return subteamToTeamMap.get(subteam.objectId) || null; + }; + + // Group regular applications by Team -> Subteam + for (const app of regularApplications) { + const subteam = app.applicationSubteam; + const team = getTeamForSubteam(subteam); + + if (team) { + const teamId = team.objectId; + if (!teamMap.has(teamId)) { + teamMap.set(teamId, { team, subteams: new Map() }); + } + const teamData = teamMap.get(teamId)!; + + const subteamId = subteam?.objectId || 'no-subteam'; + if (!teamData.subteams.has(subteamId)) { + teamData.subteams.set(subteamId, { + subteam: subteam || null, + regular: [], + platforms: [], + }); + } + teamData.subteams.get(subteamId)!.regular.push(app); + } else if (subteam) { + // Has subteam but no team - put under a virtual "Geen Team" team + const noTeamId = 'no-team'; + if (!teamMap.has(noTeamId)) { + teamMap.set(noTeamId, { + team: { objectId: noTeamId, key: 'NO-TEAM', name: 'Geen Team' }, + subteams: new Map() + }); + } + const teamData = teamMap.get(noTeamId)!; + const subteamId = subteam.objectId; + if (!teamData.subteams.has(subteamId)) { + teamData.subteams.set(subteamId, { + subteam: subteam, + regular: [], + platforms: [], + }); + } + teamData.subteams.get(subteamId)!.regular.push(app); + } else { + // No subteam assigned - goes to unassigned + unassignedData.regular.push(app); + } + } + + // Group platforms by Team -> Subteam + for (const pwl of platformsWithWorkloads) { + const platform = pwl.platform; + const subteam = platform.applicationSubteam; + const team = getTeamForSubteam(subteam); + + if (team) { + const teamId = team.objectId; + if (!teamMap.has(teamId)) { + teamMap.set(teamId, { team, subteams: new Map() }); + } + const teamData = teamMap.get(teamId)!; + + const subteamId = subteam?.objectId || 'no-subteam'; + if (!teamData.subteams.has(subteamId)) { + teamData.subteams.set(subteamId, { + subteam: subteam || null, + regular: [], + platforms: [], + }); + } + teamData.subteams.get(subteamId)!.platforms.push(pwl); + } else if (subteam) { + // Has subteam but no team - put under "Geen Team" + const noTeamId = 'no-team'; + if (!teamMap.has(noTeamId)) { + teamMap.set(noTeamId, { + team: { objectId: noTeamId, key: 'NO-TEAM', name: 'Geen Team' }, + subteams: new Map() + }); + } + const teamData = teamMap.get(noTeamId)!; + const subteamId = subteam.objectId; + if (!teamData.subteams.has(subteamId)) { + teamData.subteams.set(subteamId, { + subteam: subteam, + regular: [], + platforms: [], + }); + } + teamData.subteams.get(subteamId)!.platforms.push(pwl); + } else { + // No subteam assigned - goes to unassigned + unassignedData.platforms.push(pwl); + } + } + + logger.info(`Team dashboard: grouped ${regularApplications.length} regular apps and ${platformsWithWorkloads.length} platforms into ${teamMap.size} teams`); + + // Build the hierarchical result structure + const teams: TeamDashboardTeam[] = []; + + // Process teams in alphabetical order + const sortedTeamIds = Array.from(teamMap.keys()).sort((a, b) => { + const teamA = teamMap.get(a)!.team?.name || ''; + const teamB = teamMap.get(b)!.team?.name || ''; + return teamA.localeCompare(teamB, 'nl', { sensitivity: 'base' }); + }); + + for (const teamId of sortedTeamIds) { + const teamData = teamMap.get(teamId)!; + const fullTeam = teamData.team; + + const subteams: TeamDashboardSubteam[] = []; + + // Sort subteams alphabetically (with "no-subteam" at the end) + const sortedSubteamEntries = Array.from(teamData.subteams.entries()).sort((a, b) => { + if (a[0] === 'no-subteam') return 1; + if (b[0] === 'no-subteam') return -1; + const nameA = a[1].subteam?.name || ''; + const nameB = b[1].subteam?.name || ''; + return nameA.localeCompare(nameB, 'nl', { sensitivity: 'base' }); + }); + + for (const [subteamId, subteamData] of sortedSubteamEntries) { + const kpis = calculateSubteamKPIs(subteamData.regular, subteamData.platforms); + + subteams.push({ + subteam: subteamData.subteam, + applications: subteamData.regular, + platforms: subteamData.platforms, + ...kpis, + }); + } + + // Aggregate team KPIs from all subteams + const teamTotalEffort = subteams.reduce((sum, s) => sum + s.totalEffort, 0); + const teamMinEffort = subteams.reduce((sum, s) => sum + s.minEffort, 0); + const teamMaxEffort = subteams.reduce((sum, s) => sum + s.maxEffort, 0); + const teamApplicationCount = subteams.reduce((sum, s) => sum + s.applicationCount, 0); + const teamByGovernanceModel: Record = {}; + for (const subteam of subteams) { + for (const [model, count] of Object.entries(subteam.byGovernanceModel)) { + teamByGovernanceModel[model] = (teamByGovernanceModel[model] || 0) + count; + } + } + + teams.push({ + team: fullTeam, + subteams, + totalEffort: teamTotalEffort, + minEffort: teamMinEffort, + maxEffort: teamMaxEffort, + applicationCount: teamApplicationCount, + byGovernanceModel: teamByGovernanceModel, + }); + } + + // Calculate unassigned KPIs + const unassignedKPIs = calculateSubteamKPIs(unassignedData.regular, unassignedData.platforms); + + const result: TeamDashboardData = { + teams, + unassigned: { + subteam: null, + applications: unassignedData.regular, + platforms: unassignedData.platforms, + ...unassignedKPIs, + }, + }; + + logger.info(`Team dashboard data loaded from database: ${teams.length} teams, ${unassignedData.regular.length + unassignedData.platforms.length} unassigned apps`); + return result; + } catch (error) { + logger.error('Failed to get team dashboard data from database', error); + // Fallback to API if database fails + logger.warn('Falling back to API for team dashboard data'); + return jiraAssetsService.getTeamDashboardData(excludedStatuses); + } }, /**