import { useEffect, useState, useMemo, useRef, useCallback } from 'react'; import { Link } from 'react-router-dom'; import { getTeamDashboardData, getReferenceData } from '../services/api'; import type { TeamDashboardData, TeamDashboardTeam, TeamDashboardSubteam, ApplicationStatus, ReferenceValue } from '../types'; import PageHeader from './PageHeader'; const ALL_STATUSES: ApplicationStatus[] = [ 'In Production', 'Implementation', 'Proof of Concept', 'End of support', 'End of life', 'Deprecated', 'Shadow IT', 'Closed', 'Undefined', ]; type SortOption = 'alphabetical' | 'fte-descending'; // Color scheme for governance models - matches exact names from Jira Assets const GOVERNANCE_MODEL_COLORS: Record = { 'Regiemodel A': { bg: '#20556B', text: '#FFFFFF', letter: 'A' }, 'Regiemodel B': { bg: '#286B86', text: '#FFFFFF', letter: 'B' }, 'Regiemodel B+': { bg: '#286B86', text: '#FFFFFF', letter: 'B+' }, 'Regiemodel C': { bg: '#81CBF2', text: '#20556B', letter: 'C' }, 'Regiemodel D': { bg: '#F5A733', text: '#FFFFFF', letter: 'D' }, 'Regiemodel E': { bg: '#E95053', text: '#FFFFFF', letter: 'E' }, 'Niet ingesteld': { bg: '#EEEEEE', text: '#AAAAAA', letter: '?' }, }; // Get governance model colors and letter - with fallback for unknown models const getGovernanceModelStyle = (governanceModelName: string | null | undefined) => { const name = governanceModelName || 'Niet ingesteld'; // First try exact match if (GOVERNANCE_MODEL_COLORS[name]) { return GOVERNANCE_MODEL_COLORS[name]; } // Try to match by pattern (e.g., "Regiemodel X" -> letter X) const match = name.match(/Regiemodel\s+(.+)/i); if (match) { const letter = match[1]; // Return a color based on the letter if (letter === 'A') return { bg: '#20556B', text: '#FFFFFF', letter: 'A' }; if (letter === 'B') return { bg: '#286B86', text: '#FFFFFF', letter: 'B' }; if (letter === 'B+') return { bg: '#286B86', text: '#FFFFFF', letter: 'B+' }; if (letter === 'C') return { bg: '#81CBF2', text: '#20556B', letter: 'C' }; if (letter === 'D') return { bg: '#F5A733', text: '#FFFFFF', letter: 'D' }; if (letter === 'E') return { bg: '#E95053', text: '#FFFFFF', letter: 'E' }; return { bg: '#6B7280', text: '#FFFFFF', letter }; } return { bg: '#6B7280', text: '#FFFFFF', letter: '?' }; }; export default function TeamDashboard() { const [data, setData] = useState(null); const [initialLoading, setInitialLoading] = useState(true); // Only for first load const [dataLoading, setDataLoading] = useState(false); // For filter changes const [error, setError] = useState(null); const [expandedTeams, setExpandedTeams] = useState>(new Set()); // Track expanded teams const [expandedSubteams, setExpandedSubteams] = useState>(new Set()); // Track expanded subteams const [expandedPlatforms, setExpandedPlatforms] = useState>(new Set()); // Track expanded platforms // Status filter: excludedStatuses contains statuses that are NOT shown const [excludedStatuses, setExcludedStatuses] = useState(['Closed', 'Deprecated']); // Default: exclude Closed and Deprecated const [sortOption, setSortOption] = useState('fte-descending'); const [statusDropdownOpen, setStatusDropdownOpen] = useState(false); const [governanceModels, setGovernanceModels] = useState([]); const [hoveredGovModel, setHoveredGovModel] = useState(null); const hoverTimeoutRef = useRef | 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); } }; }, []); // Fetch governance models on mount useEffect(() => { async function fetchGovernanceModels() { try { const refData = await getReferenceData(); setGovernanceModels(refData.governanceModels); } catch (err) { console.error('Failed to fetch governance models:', err); } } fetchGovernanceModels(); }, []); // Compute overall KPIs from data const overallKPIs = useMemo(() => { if (!data) return null; // Sum up total FTE (including min/max bandwidth) // Ensure all values are numbers (handle null/undefined/NaN) let totalFTE = 0; let totalMinFTE = 0; let totalMaxFTE = 0; let totalApplicationCount = 0; const overallByGovernanceModel: Record = {}; // Aggregate from all teams data.teams.forEach(team => { totalFTE += Number(team.totalEffort) || 0; totalMinFTE += Number(team.minEffort) || 0; totalMaxFTE += Number(team.maxEffort) || 0; totalApplicationCount += Number(team.applicationCount) || 0; // Aggregate governance model distribution Object.entries(team.byGovernanceModel).forEach(([model, count]) => { overallByGovernanceModel[model] = (overallByGovernanceModel[model] || 0) + (Number(count) || 0); }); }); // Add unassigned totalFTE += Number(data.unassigned.totalEffort) || 0; totalMinFTE += Number(data.unassigned.minEffort) || 0; totalMaxFTE += Number(data.unassigned.maxEffort) || 0; totalApplicationCount += Number(data.unassigned.applicationCount) || 0; Object.entries(data.unassigned.byGovernanceModel).forEach(([model, count]) => { overallByGovernanceModel[model] = (overallByGovernanceModel[model] || 0) + (Number(count) || 0); }); return { totalFTE, totalMinFTE, totalMaxFTE, totalApplicationCount, byGovernanceModel: overallByGovernanceModel, }; }, [data]); // Sort teams always alphabetically (sortOption only affects items within teams/subteams) const sortedTeams = useMemo(() => { if (!data) return []; return [...data.teams].sort((a, b) => { const nameA = a.team?.name || ''; const nameB = b.team?.name || ''; return nameA.localeCompare(nameB, 'nl', { sensitivity: 'base' }); }); }, [data]); useEffect(() => { async function fetchData() { try { // Only show full page loading on initial load const isInitialLoad = data === null; if (isInitialLoad) { setInitialLoading(true); } else { setDataLoading(true); } const dashboardData = await getTeamDashboardData(excludedStatuses); setData(dashboardData); // Keep subteams collapsed by default (expandedSubteams remains empty) } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load team dashboard'); } finally { setInitialLoading(false); setDataLoading(false); } } fetchData(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [excludedStatuses]); // Close status dropdown when pressing Escape useEffect(() => { const handleEscape = (event: KeyboardEvent) => { if (event.key === 'Escape' && statusDropdownOpen) { setStatusDropdownOpen(false); } }; if (statusDropdownOpen) { document.addEventListener('keydown', handleEscape); } return () => { document.removeEventListener('keydown', handleEscape); }; }, [statusDropdownOpen]); const toggleTeam = (teamId: string, _event?: React.MouseEvent) => { const scrollY = window.scrollY; setExpandedTeams(prev => { const newSet = new Set(prev); if (newSet.has(teamId)) { newSet.delete(teamId); } else { newSet.add(teamId); } return newSet; }); requestAnimationFrame(() => { window.scrollTo(0, scrollY); }); }; const toggleSubteam = (subteamId: string, e?: React.MouseEvent) => { if (e) { e.preventDefault(); e.stopPropagation(); } const scrollY = window.scrollY; setExpandedSubteams(prev => { const newSet = new Set(prev); if (newSet.has(subteamId)) { newSet.delete(subteamId); } else { newSet.add(subteamId); } return newSet; }); requestAnimationFrame(() => { window.scrollTo(0, scrollY); }); }; const togglePlatform = (platformId: string, e?: React.MouseEvent) => { if (e) { e.preventDefault(); e.stopPropagation(); } setExpandedPlatforms(prev => { const newSet = new Set(prev); if (newSet.has(platformId)) { newSet.delete(platformId); } else { newSet.add(platformId); } return newSet; }); }; const toggleStatus = (status: ApplicationStatus) => { setExcludedStatuses(prev => { if (prev.includes(status)) { // Remove from excluded (show it) return prev.filter(s => s !== status); } else { // Add to excluded (hide it) return [...prev, status]; } }); }; // Only show full page loading on initial load if (initialLoading) { return (
); } const hasNoApplications = data ? ( data.teams.length === 0 && data.unassigned.applications.length === 0 && data.unassigned.platforms.length === 0 ) : true; // Helper function to get effective FTE for an application const getEffectiveFTE = (app: { overrideFTE?: number | null; requiredEffortApplicationManagement?: number | null }) => app.overrideFTE !== null && app.overrideFTE !== undefined ? app.overrideFTE : (app.requiredEffortApplicationManagement || 0); // Color scheme for team types const TEAM_TYPE_COLORS: Record = { 'Business': { bg: 'bg-green-200', text: 'text-green-900' }, 'Enabling': { bg: 'bg-blue-200', text: 'text-blue-900' }, 'Staf': { bg: 'bg-purple-200', text: 'text-purple-900' }, }; // Render applications (platforms + regular apps) for a subteam const renderApplications = (subteamData: TeamDashboardSubteam, sortOpt: SortOption) => { const sortedApplications = [...subteamData.applications].sort((a, b) => { if (sortOpt === 'alphabetical') { return a.name.localeCompare(b.name, 'nl', { sensitivity: 'base' }); } else { return getEffectiveFTE(b) - getEffectiveFTE(a); } }); const sortedPlatforms = [...subteamData.platforms].sort((a, b) => { if (sortOpt === 'alphabetical') { return a.platform.name.localeCompare(b.platform.name, 'nl', { sensitivity: 'base' }); } else { return b.totalEffort - a.totalEffort; } }); if (sortedApplications.length === 0 && sortedPlatforms.length === 0) { return

Geen applicaties in dit subteam

; } return (
{/* Platforms with Workloads */} {sortedPlatforms.map((platformWithWorkloads) => { const platformId = platformWithWorkloads.platform.id; const isPlatformExpanded = expandedPlatforms.has(platformId); const hasWorkloads = platformWithWorkloads.workloads.length > 0; const platformGovStyle = getGovernanceModelStyle(platformWithWorkloads.platform.governanceModel?.name); const platform = platformWithWorkloads.platform; return (
{platformGovStyle.letter}
{hasWorkloads && ( )}
{platform.name}
Platform
{platform.key}
Platform: {platformWithWorkloads.platformEffort.toFixed(2)} FTE
{platformWithWorkloads.workloads.length > 0 && (
Workloads: {platformWithWorkloads.workloadsEffort.toFixed(2)} FTE
)}
{hasWorkloads && isPlatformExpanded && (
{platformWithWorkloads.workloads.map((workload) => { const workloadGovStyle = getGovernanceModelStyle(workload.governanceModel?.name); return (
{workloadGovStyle.letter}
{workload.name}
{workload.key}
{getEffectiveFTE(workload).toFixed(2)} FTE
); })}
)}
); })} {/* Regular applications */} {sortedApplications.map((app) => { const govStyle = getGovernanceModelStyle(app.governanceModel?.name); const effectiveFTE = getEffectiveFTE(app); const isConnectedDevice = app.applicationType?.name === 'Connected Device'; return (
{govStyle.letter}
{app.name} {isConnectedDevice && ( Connected Device )}
{app.key}
{effectiveFTE.toFixed(2)} FTE
); })}
); }; // SubteamBlock component - shows subteam with SUBTEAM label const SubteamBlock = ({ subteamData, teamId, sortOpt }: { subteamData: TeamDashboardSubteam; teamId: string; sortOpt: SortOption }) => { const subteamId = `${teamId}-${subteamData.subteam?.objectId || 'no-subteam'}`; const isExpanded = expandedSubteams.has(subteamId); const subteamName = subteamData.subteam?.name || 'Geen subteam'; return (
toggleSubteam(subteamId, e)} className="w-full px-4 py-3 hover:bg-gray-50 transition-colors cursor-pointer">
{isExpanded ? ( ) : ( )}

{subteamName}

SUBTEAM {/* Governance Model KPI badges - show all models including 0 */}
{[ ...governanceModels .map(g => g.name) .sort((a, b) => a.localeCompare(b, 'nl', { sensitivity: 'base' })), 'Niet ingesteld' ].map((model) => { const count = subteamData.byGovernanceModel[model] || 0; const style = getGovernanceModelStyle(model); const hoverKey = `subteam-${subteamId}-${model}`; const isHovered = hoveredGovModel === hoverKey; const govModelData = governanceModels.find(g => g.name === model); return (
handleGovModelMouseEnter(hoverKey)} onMouseLeave={handleGovModelMouseLeave} >
{style.letter} {count}
{/* Hover popup - outside of opacity-affected element */} {isHovered && model !== 'Niet ingesteld' && govModelData && (
e.stopPropagation()} > {/* Arrow pointer */}
{/* Header: Summary (Description) */}
{govModelData.summary || model} {govModelData.description && ( ({govModelData.description}) )}
{/* Remarks */} {govModelData.remarks && (
{govModelData.remarks}
)} {/* Application section */} {govModelData.application && (
Toepassing
{govModelData.application}
)} {/* Fallback message if no data */} {!govModelData.summary && !govModelData.remarks && !govModelData.application && (
Geen aanvullende informatie beschikbaar
)}
)}
); })}
{subteamData.totalEffort.toFixed(2)} FTE
{subteamData.applicationCount} applicaties {(subteamData.minEffort !== undefined && subteamData.maxEffort !== undefined) && ( ({subteamData.minEffort.toFixed(2)} - {subteamData.maxEffort.toFixed(2)} FTE) )}
{isExpanded && (
{renderApplications(subteamData, sortOpt)}
)}
); }; // TeamBlock component - shows team with TEAM label and Type badge const TeamBlock = ({ teamData, sortOpt }: { teamData: TeamDashboardTeam; sortOpt: SortOption }) => { const teamId = teamData.team?.objectId || 'unassigned'; const isExpanded = expandedTeams.has(teamId); const teamName = teamData.team?.name || 'Onbekend'; const teamType = teamData.team?.teamType; const typeColors = teamType && TEAM_TYPE_COLORS[teamType] ? TEAM_TYPE_COLORS[teamType] : { bg: 'bg-gray-100', text: 'text-gray-600' }; return (
toggleTeam(teamId)} className="w-full px-6 py-4 hover:bg-gray-100 transition-colors text-left cursor-pointer">
{isExpanded ? ( ) : ( )}

{teamName}

{teamType ? ( {teamType} ) : ( Type onbekend )} {/* Governance Model KPI badges - show all models including 0 */}
{[ ...governanceModels .map(g => g.name) .sort((a, b) => a.localeCompare(b, 'nl', { sensitivity: 'base' })), 'Niet ingesteld' ].map((model) => { const count = teamData.byGovernanceModel[model] || 0; const style = getGovernanceModelStyle(model); const hoverKey = `team-${teamId}-${model}`; const isHovered = hoveredGovModel === hoverKey; const govModelData = governanceModels.find(g => g.name === model); return (
handleGovModelMouseEnter(hoverKey)} onMouseLeave={handleGovModelMouseLeave} >
{style.letter} {count}
{/* Hover popup - outside of opacity-affected element */} {isHovered && model !== 'Niet ingesteld' && govModelData && (
e.stopPropagation()} > {/* Arrow pointer */}
{/* Header: Summary (Description) */}
{govModelData.summary || model} {govModelData.description && ( ({govModelData.description}) )}
{/* Remarks */} {govModelData.remarks && (
{govModelData.remarks}
)} {/* Application section */} {govModelData.application && (
Toepassing
{govModelData.application}
)} {/* Fallback message if no data */} {!govModelData.summary && !govModelData.remarks && !govModelData.application && (
Geen aanvullende informatie beschikbaar
)}
)}
); })}
{teamData.totalEffort.toFixed(2)} FTE
{teamData.applicationCount} applicaties • {teamData.subteams.length} subteams {(teamData.minEffort !== undefined && teamData.maxEffort !== undefined) && ( ({teamData.minEffort.toFixed(2)} - {teamData.maxEffort.toFixed(2)} FTE) )}
{isExpanded && (
{teamData.subteams.length === 0 ? (

Geen subteams

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

Uitgevinkte statussen worden verborgen

)}
{/* KPI Bar */} {overallKPIs && (
{/* Total FTE - Primary KPI */}
Totaal FTE
{(Number(overallKPIs.totalFTE) || 0).toFixed(2)} FTE
Bandbreedte: {(Number(overallKPIs.totalMinFTE) || 0).toFixed(2)} - {(Number(overallKPIs.totalMaxFTE) || 0).toFixed(2)} FTE
{/* Application Count */}
Application Components
{Number(overallKPIs.totalApplicationCount) || 0}
weergegeven
{/* Governance Model Distribution */}
Per Regiemodel
{/* Show all governance models from Jira Assets + "Niet ingesteld" */} {[ ...governanceModels .map(g => g.name) .sort((a, b) => a.localeCompare(b, 'nl', { sensitivity: 'base' })), 'Niet ingesteld' ].map((govModel) => { const count = overallKPIs.byGovernanceModel[govModel] || 0; const colors = (() => { if (govModel.includes('Regiemodel A')) return { bg: '#20556B', text: '#FFFFFF' }; if (govModel.includes('Regiemodel B+') || govModel.includes('B+')) return { bg: '#286B86', text: '#FFFFFF' }; if (govModel.includes('Regiemodel B')) return { bg: '#286B86', text: '#FFFFFF' }; if (govModel.includes('Regiemodel C')) return { bg: '#81CBF2', text: '#20556B' }; if (govModel.includes('Regiemodel D')) return { bg: '#F5A733', text: '#FFFFFF' }; if (govModel.includes('Regiemodel E')) return { bg: '#E95053', text: '#FFFFFF' }; if (govModel === 'Niet ingesteld') return { bg: '#4B5563', 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 hoverKey = `header-${govModel}`; const isHovered = hoveredGovModel === hoverKey; return (
handleGovModelMouseEnter(hoverKey)} onMouseLeave={handleGovModelMouseLeave} >
{shortLabel}
{count}
{/* Hover popup */} {isHovered && govModel !== 'Niet ingesteld' && (
{/* Arrow pointer */}
{/* Header: Summary (Description) */}
{govModelData?.summary || govModel} {govModelData?.description && ( ({govModelData.description}) )}
{/* Remarks */} {govModelData?.remarks && (
{govModelData.remarks}
)} {/* Application section */} {govModelData?.application && (
Toepassing
{govModelData.application}
)} {/* Fallback message if no data */} {!govModelData && (
Geen aanvullende informatie beschikbaar
)}
)}
); })}
)} {/* Error message */} {error && (
{error}
)} {/* Loading indicator for data updates */} {dataLoading && (
Resultaten bijwerken...
)} {/* Teams */} {!dataLoading && data && sortedTeams.length > 0 && (
{sortedTeams.map((teamData) => ( ))}
)} {/* Unassigned applications */} {!dataLoading && data && (data.unassigned.applications.length > 0 || data.unassigned.platforms.length > 0) && (

Nog niet toegekend

{/* Governance Model KPI badges - show all models including 0 */}
{[ ...governanceModels .map(g => g.name) .sort((a, b) => a.localeCompare(b, 'nl', { sensitivity: 'base' })), 'Niet ingesteld' ].map((model) => { const count = data.unassigned.byGovernanceModel[model] || 0; const style = getGovernanceModelStyle(model); const hoverKey = `unassigned-${model}`; const isHovered = hoveredGovModel === hoverKey; const govModelData = governanceModels.find(g => g.name === model); return (
handleGovModelMouseEnter(hoverKey)} onMouseLeave={handleGovModelMouseLeave} >
{style.letter} {count}
{/* Hover popup - outside of opacity-affected element */} {isHovered && model !== 'Niet ingesteld' && govModelData && (
e.stopPropagation()} > {/* Arrow pointer */}
{/* Header: Summary (Description) */}
{govModelData.summary || model} {govModelData.description && ( ({govModelData.description}) )}
{/* Remarks */} {govModelData.remarks && (
{govModelData.remarks}
)} {/* Application section */} {govModelData.application && (
Toepassing
{govModelData.application}
)} {/* Fallback message if no data */} {!govModelData.summary && !govModelData.remarks && !govModelData.application && (
Geen aanvullende informatie beschikbaar
)}
)}
); })}
{data.unassigned.totalEffort.toFixed(2)} FTE
{data.unassigned.applicationCount} applicaties {(data.unassigned.minEffort !== undefined && data.unassigned.maxEffort !== undefined) && ( ({data.unassigned.minEffort.toFixed(2)} - {data.unassigned.maxEffort.toFixed(2)} FTE) )}
{renderApplications(data.unassigned, sortOption)}

Deze applicaties zijn nog niet toegekend aan een team.

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

Geen applicaties gevonden

)}
); }