- 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
1112 lines
52 KiB
TypeScript
1112 lines
52 KiB
TypeScript
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';
|
|
|
|
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<string, { bg: string; text: string; letter: string }> = {
|
|
'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<TeamDashboardData | null>(null);
|
|
const [initialLoading, setInitialLoading] = useState(true); // Only for first load
|
|
const [dataLoading, setDataLoading] = useState(false); // For filter changes
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [expandedTeams, setExpandedTeams] = useState<Set<string>>(new Set()); // Track expanded teams
|
|
const [expandedSubteams, setExpandedSubteams] = useState<Set<string>>(new Set()); // Track expanded subteams
|
|
const [expandedPlatforms, setExpandedPlatforms] = useState<Set<string>>(new Set()); // Track expanded platforms
|
|
// Status filter: excludedStatuses contains statuses that are NOT shown
|
|
const [excludedStatuses, setExcludedStatuses] = useState<ApplicationStatus[]>(['Closed', 'Deprecated']); // Default: exclude Closed and Deprecated
|
|
const [sortOption, setSortOption] = useState<SortOption>('fte-descending');
|
|
const [statusDropdownOpen, setStatusDropdownOpen] = useState(false);
|
|
const [governanceModels, setGovernanceModels] = useState<ReferenceValue[]>([]);
|
|
const [hoveredGovModel, setHoveredGovModel] = 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);
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
// 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)
|
|
let totalFTE = 0;
|
|
let totalMinFTE = 0;
|
|
let totalMaxFTE = 0;
|
|
let totalApplicationCount = 0;
|
|
const overallByGovernanceModel: Record<string, number> = {};
|
|
|
|
// Aggregate from all teams
|
|
data.teams.forEach(team => {
|
|
totalFTE += team.totalEffort;
|
|
totalMinFTE += team.minEffort ?? 0;
|
|
totalMaxFTE += team.maxEffort ?? 0;
|
|
totalApplicationCount += team.applicationCount;
|
|
|
|
// Aggregate governance model distribution
|
|
Object.entries(team.byGovernanceModel).forEach(([model, count]) => {
|
|
overallByGovernanceModel[model] = (overallByGovernanceModel[model] || 0) + count;
|
|
});
|
|
});
|
|
|
|
// Add unassigned
|
|
totalFTE += data.unassigned.totalEffort;
|
|
totalMinFTE += data.unassigned.minEffort ?? 0;
|
|
totalMaxFTE += data.unassigned.maxEffort ?? 0;
|
|
totalApplicationCount += data.unassigned.applicationCount;
|
|
|
|
Object.entries(data.unassigned.byGovernanceModel).forEach(([model, count]) => {
|
|
overallByGovernanceModel[model] = (overallByGovernanceModel[model] || 0) + count;
|
|
});
|
|
|
|
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 (
|
|
<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>
|
|
);
|
|
}
|
|
|
|
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<string, { bg: string; text: string }> = {
|
|
'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 <p className="text-sm text-gray-500">Geen applicaties in dit subteam</p>;
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-2">
|
|
{/* 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 (
|
|
<div key={platformId} className="border border-blue-200 rounded-lg bg-blue-50 overflow-hidden flex">
|
|
<div
|
|
className="w-10 flex-shrink-0 flex items-center justify-center font-bold text-sm"
|
|
style={{ backgroundColor: platformGovStyle.bg, color: platformGovStyle.text }}
|
|
title={platform.governanceModel?.name || 'Niet ingesteld'}
|
|
>
|
|
{platformGovStyle.letter}
|
|
</div>
|
|
|
|
<div className="flex-1">
|
|
<div className="flex items-stretch">
|
|
{hasWorkloads && (
|
|
<button
|
|
onClick={(e) => togglePlatform(platformId, e)}
|
|
className="px-2 hover:bg-blue-100 transition-colors flex-shrink-0 self-stretch flex items-center"
|
|
>
|
|
<svg className={`w-5 h-5 text-blue-700 transition-transform ${isPlatformExpanded ? 'transform rotate-90' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
|
</svg>
|
|
</button>
|
|
)}
|
|
<Link to={`/app-components/overview/${platformId}`} target="_blank" rel="noopener noreferrer" className="flex-1 p-3 hover:bg-blue-100 transition-colors">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex-1">
|
|
<div className="flex items-center space-x-2 flex-wrap gap-y-1">
|
|
<div className="font-medium text-gray-900">{platform.name}</div>
|
|
<span className="text-xs font-semibold text-blue-700 bg-blue-200 px-2 py-0.5 rounded">Platform</span>
|
|
</div>
|
|
<div className="text-sm text-gray-500">{platform.key}</div>
|
|
</div>
|
|
<div className="text-right">
|
|
<div className="text-sm font-medium text-gray-900">
|
|
Platform: {platformWithWorkloads.platformEffort.toFixed(2)} FTE
|
|
</div>
|
|
{platformWithWorkloads.workloads.length > 0 && (
|
|
<div className="text-xs text-gray-600">
|
|
Workloads: {platformWithWorkloads.workloadsEffort.toFixed(2)} FTE
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</Link>
|
|
</div>
|
|
|
|
{hasWorkloads && isPlatformExpanded && (
|
|
<div className="border-t border-blue-200 bg-white">
|
|
<div className="divide-y divide-gray-200">
|
|
{platformWithWorkloads.workloads.map((workload) => {
|
|
const workloadGovStyle = getGovernanceModelStyle(workload.governanceModel?.name);
|
|
return (
|
|
<div key={workload.id} className="flex items-stretch">
|
|
<div className="w-8 flex-shrink-0 flex items-center justify-center font-bold text-xs" style={{ backgroundColor: workloadGovStyle.bg, color: workloadGovStyle.text, opacity: 0.7 }}>
|
|
{workloadGovStyle.letter}
|
|
</div>
|
|
<Link to={`/app-components/overview/${workload.id}`} target="_blank" rel="noopener noreferrer" className="flex-1 px-4 py-2 hover:bg-gray-50 transition-colors">
|
|
<div className="flex items-center justify-between">
|
|
<div><span className="text-sm font-medium text-gray-700">{workload.name}</span><div className="text-xs text-gray-500">{workload.key}</div></div>
|
|
<div className="text-xs font-medium text-gray-600">{getEffectiveFTE(workload).toFixed(2)} FTE</div>
|
|
</div>
|
|
</Link>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
{/* Regular applications */}
|
|
{sortedApplications.map((app) => {
|
|
const govStyle = getGovernanceModelStyle(app.governanceModel?.name);
|
|
const effectiveFTE = getEffectiveFTE(app);
|
|
const isConnectedDevice = app.applicationType?.name === 'Connected Device';
|
|
|
|
return (
|
|
<Link key={app.id} to={`/app-components/overview/${app.id}`} target="_blank" rel="noopener noreferrer" className="flex items-stretch bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors overflow-hidden">
|
|
<div className="w-10 flex-shrink-0 flex items-center justify-center font-bold text-sm" style={{ backgroundColor: govStyle.bg, color: govStyle.text }}>{govStyle.letter}</div>
|
|
<div className="flex-1 p-3 flex items-center justify-between">
|
|
<div className="flex-1">
|
|
<div className="flex items-center space-x-2 flex-wrap gap-y-1">
|
|
<span className="font-medium text-gray-900">{app.name}</span>
|
|
{isConnectedDevice && (
|
|
<span className="text-xs font-semibold text-orange-700 bg-orange-200 px-2 py-0.5 rounded">Connected Device</span>
|
|
)}
|
|
</div>
|
|
<div className="text-sm text-gray-500">{app.key}</div>
|
|
</div>
|
|
<div className="text-sm font-medium text-gray-900">{effectiveFTE.toFixed(2)} FTE</div>
|
|
</div>
|
|
</Link>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// 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 (
|
|
<div className="bg-white rounded-lg border border-gray-200 mb-2">
|
|
<div onClick={(e) => toggleSubteam(subteamId, e)} className="w-full px-4 py-3 hover:bg-gray-50 transition-colors cursor-pointer">
|
|
<div className="flex items-center space-x-3">
|
|
<div className="flex-shrink-0">
|
|
{isExpanded ? (
|
|
<svg className="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
|
|
) : (
|
|
<svg className="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /></svg>
|
|
)}
|
|
</div>
|
|
<h4 className="text-base font-medium text-gray-900">{subteamName}</h4>
|
|
<span className="text-[10px] font-semibold text-white bg-indigo-300 px-2 py-0.5 rounded">SUBTEAM</span>
|
|
|
|
{/* Governance Model KPI badges - show all models including 0 */}
|
|
<div className="flex items-center gap-1 ml-2" style={{ position: 'relative' }}>
|
|
{[
|
|
...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 (
|
|
<div
|
|
key={model}
|
|
className="relative cursor-pointer"
|
|
onMouseEnter={() => handleGovModelMouseEnter(hoverKey)}
|
|
onMouseLeave={handleGovModelMouseLeave}
|
|
>
|
|
<div
|
|
className="flex items-center justify-center rounded px-1.5 py-0.5 text-xs font-bold min-w-[24px] hover:shadow-md transition-all"
|
|
style={{
|
|
backgroundColor: style.bg,
|
|
color: style.text,
|
|
opacity: count === 0 ? 0.4 : 1
|
|
}}
|
|
>
|
|
<span className="text-[9px] mr-0.5">{style.letter}</span>
|
|
<span>{count}</span>
|
|
</div>
|
|
|
|
{/* Hover popup - outside of opacity-affected element */}
|
|
{isHovered && model !== 'Niet ingesteld' && govModelData && (
|
|
<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'
|
|
}}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{/* Arrow pointer */}
|
|
<div
|
|
className="absolute -top-2 left-4 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 || model}
|
|
{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.summary && !govModelData.remarks && !govModelData.application && (
|
|
<div className="text-xs text-gray-400 italic">
|
|
Geen aanvullende informatie beschikbaar
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<div className="flex-1" />
|
|
<div className="text-right">
|
|
<div className="text-lg font-bold text-emerald-700">{subteamData.totalEffort.toFixed(2)} FTE</div>
|
|
<div className="text-xs text-gray-500">
|
|
{subteamData.applicationCount} applicaties
|
|
{(subteamData.minEffort !== undefined && subteamData.maxEffort !== undefined) && (
|
|
<span className="ml-2 text-gray-400">
|
|
({subteamData.minEffort.toFixed(2)} - {subteamData.maxEffort.toFixed(2)} FTE)
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{isExpanded && (
|
|
<div className="border-t border-gray-200 px-4 py-3">
|
|
{renderApplications(subteamData, sortOpt)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// 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 (
|
|
<div className="bg-gray-50 rounded-lg shadow-sm border border-gray-200 mb-4" style={{ overflow: 'visible' }}>
|
|
<div onClick={() => toggleTeam(teamId)} className="w-full px-6 py-4 hover:bg-gray-100 transition-colors text-left cursor-pointer">
|
|
<div className="flex items-center space-x-4">
|
|
<div className="flex-shrink-0">
|
|
{isExpanded ? (
|
|
<svg className="w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
|
|
) : (
|
|
<svg className="w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /></svg>
|
|
)}
|
|
</div>
|
|
<h3 className="text-lg font-semibold text-gray-900">{teamName}</h3>
|
|
{teamType ? (
|
|
<span className={`text-sm font-semibold px-2 py-0.5 rounded ${typeColors.bg} ${typeColors.text}`}>{teamType}</span>
|
|
) : (
|
|
<span className="text-sm font-semibold text-gray-600 bg-gray-100 px-2 py-0.5 rounded">Type onbekend</span>
|
|
)}
|
|
|
|
{/* Governance Model KPI badges - show all models including 0 */}
|
|
<div className="flex items-center gap-1.5 ml-2" style={{ position: 'relative' }}>
|
|
{[
|
|
...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 (
|
|
<div
|
|
key={model}
|
|
className="relative cursor-pointer"
|
|
onMouseEnter={() => handleGovModelMouseEnter(hoverKey)}
|
|
onMouseLeave={handleGovModelMouseLeave}
|
|
>
|
|
<div
|
|
className="flex items-center justify-center rounded px-2 py-1 text-sm font-bold min-w-[32px] hover:shadow-md transition-all"
|
|
style={{
|
|
backgroundColor: style.bg,
|
|
color: style.text,
|
|
opacity: count === 0 ? 0.4 : 1
|
|
}}
|
|
>
|
|
<span className="text-[10px] mr-2">{style.letter}</span>
|
|
<span>{count}</span>
|
|
</div>
|
|
|
|
{/* Hover popup - outside of opacity-affected element */}
|
|
{isHovered && model !== 'Niet ingesteld' && govModelData && (
|
|
<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'
|
|
}}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{/* Arrow pointer */}
|
|
<div
|
|
className="absolute -top-2 left-4 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 || model}
|
|
{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.summary && !govModelData.remarks && !govModelData.application && (
|
|
<div className="text-xs text-gray-400 italic">
|
|
Geen aanvullende informatie beschikbaar
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<div className="flex-1" />
|
|
<div className="text-right">
|
|
<div className="text-2xl font-bold text-emerald-700">{teamData.totalEffort.toFixed(2)} FTE</div>
|
|
<div className="text-sm text-gray-500">
|
|
{teamData.applicationCount} applicaties • {teamData.subteams.length} subteams
|
|
{(teamData.minEffort !== undefined && teamData.maxEffort !== undefined) && (
|
|
<span className="ml-2 text-gray-400">
|
|
({teamData.minEffort.toFixed(2)} - {teamData.maxEffort.toFixed(2)} FTE)
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{isExpanded && (
|
|
<div className="border-t border-gray-200 px-6 py-4 bg-gray-100">
|
|
{teamData.subteams.length === 0 ? (
|
|
<p className="text-sm text-gray-500">Geen subteams</p>
|
|
) : (
|
|
teamData.subteams.map((subteamData, idx) => (
|
|
<SubteamBlock key={subteamData.subteam?.objectId || `no-subteam-${idx}`} subteamData={subteamData} teamId={teamId} sortOpt={sortOpt} />
|
|
))
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
<div className="mb-6">
|
|
<h1 className="text-2xl font-bold text-gray-900">Team-indeling</h1>
|
|
<p className="mt-1 text-sm text-gray-500">
|
|
Overzicht van applicaties gegroepeerd per Team en Subteam
|
|
</p>
|
|
</div>
|
|
|
|
{/* Compact Filter Bar */}
|
|
<div className="mb-6 bg-gray-50 rounded-lg shadow-sm border border-gray-200 p-3">
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
{/* Sort Option */}
|
|
<div className="flex items-center gap-2">
|
|
<label className="text-xs font-medium text-gray-600 whitespace-nowrap">
|
|
Sorteer:
|
|
</label>
|
|
<select
|
|
value={sortOption}
|
|
onChange={(e) => setSortOption(e.target.value as SortOption)}
|
|
className="px-2.5 py-1.5 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500 text-sm bg-white min-w-[140px]"
|
|
>
|
|
<option value="alphabetical">Alfabetisch</option>
|
|
<option value="fte-descending">FTE (aflopend)</option>
|
|
</select>
|
|
</div>
|
|
|
|
{/* Status Filter Dropdown */}
|
|
<div className="flex items-center gap-2 relative">
|
|
<label className="text-xs font-medium text-gray-600 whitespace-nowrap">
|
|
Status:
|
|
</label>
|
|
<div className="relative">
|
|
<button
|
|
type="button"
|
|
onClick={() => setStatusDropdownOpen(!statusDropdownOpen)}
|
|
className="px-2.5 py-1.5 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500 text-sm bg-white min-w-[180px] text-left flex items-center justify-between gap-2 hover:bg-gray-50"
|
|
>
|
|
<span className="text-gray-700">
|
|
{excludedStatuses.length === 0
|
|
? 'Alle statussen'
|
|
: excludedStatuses.length === 1
|
|
? `${ALL_STATUSES.length - 1} van ${ALL_STATUSES.length}`
|
|
: `${ALL_STATUSES.length - excludedStatuses.length} van ${ALL_STATUSES.length}`}
|
|
</span>
|
|
<svg
|
|
className={`w-4 h-4 text-gray-400 transition-transform ${statusDropdownOpen ? '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>
|
|
|
|
{statusDropdownOpen && (
|
|
<>
|
|
<div
|
|
className="fixed inset-0 z-10"
|
|
onClick={() => setStatusDropdownOpen(false)}
|
|
/>
|
|
<div className="absolute z-20 mt-1 w-64 bg-white border border-gray-300 rounded-md shadow-lg max-h-80 overflow-auto">
|
|
<div className="p-2 border-b border-gray-200 flex items-center justify-between">
|
|
<span className="text-xs font-semibold text-gray-700">Selecteer statussen</span>
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setExcludedStatuses(['Closed', 'Deprecated']);
|
|
setStatusDropdownOpen(false);
|
|
}}
|
|
className="text-xs text-blue-600 hover:text-blue-700 font-medium"
|
|
>
|
|
Reset
|
|
</button>
|
|
</div>
|
|
<div className="p-2 space-y-1">
|
|
{ALL_STATUSES.map((status) => {
|
|
const isExcluded = excludedStatuses.includes(status);
|
|
return (
|
|
<label
|
|
key={status}
|
|
onClick={(e) => e.stopPropagation()}
|
|
className="flex items-center space-x-2 cursor-pointer p-2 rounded hover:bg-gray-50"
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={!isExcluded}
|
|
onChange={() => toggleStatus(status)}
|
|
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
|
/>
|
|
<span className="text-sm text-gray-700">{status}</span>
|
|
</label>
|
|
);
|
|
})}
|
|
</div>
|
|
<div className="p-2 border-t border-gray-200 bg-gray-50">
|
|
<p className="text-xs text-gray-500">
|
|
Uitgevinkte statussen worden verborgen
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* KPI Bar */}
|
|
{overallKPIs && (
|
|
<div className="mb-6 bg-gradient-to-r from-slate-800 to-slate-700 rounded-xl shadow-lg p-5" style={{ overflow: 'visible', position: 'relative', zIndex: hoveredGovModel?.startsWith('header-') ? 100 : 1 }}>
|
|
<div className="flex flex-wrap items-stretch gap-6" style={{ overflow: 'visible' }}>
|
|
{/* Total FTE - Primary KPI */}
|
|
<div className="bg-gradient-to-br from-emerald-500 to-teal-600 rounded-xl px-5 py-4 shadow-md min-w-[200px]">
|
|
<div className="flex items-center gap-2 text-emerald-100 text-sm font-medium mb-1">
|
|
<svg className="w-5 h-5" 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>
|
|
Totaal FTE
|
|
</div>
|
|
<div className="text-4xl font-bold text-white tracking-tight">
|
|
{overallKPIs.totalFTE.toFixed(2)} FTE
|
|
</div>
|
|
<div className="text-emerald-200 text-sm mt-1">
|
|
Bandbreedte: {overallKPIs.totalMinFTE.toFixed(2)} - {overallKPIs.totalMaxFTE.toFixed(2)} FTE
|
|
</div>
|
|
</div>
|
|
|
|
{/* Application Count */}
|
|
<div className="bg-slate-600/50 rounded-xl px-5 py-4 min-w-[160px]">
|
|
<div className="flex items-center gap-2 text-slate-300 text-sm font-medium mb-1">
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
|
</svg>
|
|
Application Components
|
|
</div>
|
|
<div className="text-4xl font-bold text-white tracking-tight">
|
|
{overallKPIs.totalApplicationCount}
|
|
</div>
|
|
<div className="text-slate-400 text-sm mt-1">
|
|
weergegeven
|
|
</div>
|
|
</div>
|
|
|
|
{/* Governance Model Distribution */}
|
|
<div className="flex-1 min-w-[300px]" style={{ overflow: 'visible' }}>
|
|
<div className="flex items-center justify-end gap-2 text-slate-300 text-sm font-medium mb-3">
|
|
<svg className="w-5 h-5" 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>
|
|
Per Regiemodel
|
|
<span className="text-slate-400 text-[10px]" title="Hover voor details">ⓘ</span>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2 justify-end" style={{ overflow: 'visible' }}>
|
|
{/* 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 (
|
|
<div
|
|
key={govModel}
|
|
className="rounded-xl py-2 px-3 text-center min-w-[52px] cursor-pointer hover:shadow-lg transition-all duration-200"
|
|
style={{ backgroundColor: colors.bg, color: colors.text, position: 'relative' }}
|
|
onMouseEnter={() => handleGovModelMouseEnter(hoverKey)}
|
|
onMouseLeave={handleGovModelMouseLeave}
|
|
>
|
|
<div className="text-[10px] font-bold uppercase tracking-wider" style={{ opacity: 0.85 }}>
|
|
{shortLabel}
|
|
</div>
|
|
<div className="text-xl font-bold leading-tight">
|
|
{count}
|
|
</div>
|
|
|
|
{/* Hover popup */}
|
|
{isHovered && govModel !== 'Niet ingesteld' && (
|
|
<div
|
|
className="absolute right-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 right-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>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Error message */}
|
|
{error && (
|
|
<div className="mb-6 bg-red-50 border border-red-200 rounded-lg p-4 text-red-700">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{/* Loading indicator for data updates */}
|
|
{dataLoading && (
|
|
<div className="mb-6 flex items-center justify-center py-4">
|
|
<div className="flex items-center gap-2 text-sm text-gray-600">
|
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600" />
|
|
<span>Resultaten bijwerken...</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Teams */}
|
|
{!dataLoading && data && sortedTeams.length > 0 && (
|
|
<div className="mb-8">
|
|
{sortedTeams.map((teamData) => (
|
|
<TeamBlock key={teamData.team?.objectId || 'unknown'} teamData={teamData} sortOpt={sortOption} />
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Unassigned applications */}
|
|
{!dataLoading && data && (data.unassigned.applications.length > 0 || data.unassigned.platforms.length > 0) && (
|
|
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
|
<div className="flex items-center space-x-3 mb-4">
|
|
<h3 className="text-lg font-semibold text-yellow-800">Nog niet toegekend</h3>
|
|
|
|
{/* Governance Model KPI badges - show all models including 0 */}
|
|
<div className="flex items-center gap-1.5 ml-2" style={{ position: 'relative' }}>
|
|
{[
|
|
...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 (
|
|
<div
|
|
key={model}
|
|
className="relative cursor-pointer"
|
|
onMouseEnter={() => handleGovModelMouseEnter(hoverKey)}
|
|
onMouseLeave={handleGovModelMouseLeave}
|
|
>
|
|
<div
|
|
className="flex items-center justify-center rounded px-2 py-1 text-sm font-bold min-w-[32px] hover:shadow-md transition-all"
|
|
style={{
|
|
backgroundColor: style.bg,
|
|
color: style.text,
|
|
opacity: count === 0 ? 0.4 : 1
|
|
}}
|
|
>
|
|
<span className="text-[10px] mr-0.5">{style.letter}</span>
|
|
<span>{count}</span>
|
|
</div>
|
|
|
|
{/* Hover popup - outside of opacity-affected element */}
|
|
{isHovered && model !== 'Niet ingesteld' && govModelData && (
|
|
<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'
|
|
}}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{/* Arrow pointer */}
|
|
<div
|
|
className="absolute -top-2 left-4 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 || model}
|
|
{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.summary && !govModelData.remarks && !govModelData.application && (
|
|
<div className="text-xs text-gray-400 italic">
|
|
Geen aanvullende informatie beschikbaar
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<div className="flex-1" />
|
|
<div className="text-right">
|
|
<div className="text-xl font-bold text-yellow-700">{data.unassigned.totalEffort.toFixed(2)} FTE</div>
|
|
<div className="text-sm text-yellow-600">
|
|
{data.unassigned.applicationCount} applicaties
|
|
{(data.unassigned.minEffort !== undefined && data.unassigned.maxEffort !== undefined) && (
|
|
<span className="ml-2 text-yellow-500">
|
|
({data.unassigned.minEffort.toFixed(2)} - {data.unassigned.maxEffort.toFixed(2)} FTE)
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{renderApplications(data.unassigned, sortOption)}
|
|
<p className="text-sm text-yellow-700 mt-4">
|
|
Deze applicaties zijn nog niet toegekend aan een team.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{!dataLoading && data && hasNoApplications && (
|
|
<div className="bg-gray-50 border border-gray-200 rounded-lg p-8 text-center">
|
|
<p className="text-gray-500">Geen applicaties gevonden</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|