Initial commit: ZiRA Classification Tool for Zuyderland CMDB
This commit is contained in:
908
frontend/src/components/TeamDashboard.tsx
Normal file
908
frontend/src/components/TeamDashboard.tsx
Normal file
@@ -0,0 +1,908 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { getTeamDashboardData, getReferenceData } from '../services/api';
|
||||
import type { TeamDashboardData, TeamDashboardCluster, 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 [expandedClusters, setExpandedClusters] = useState<Set<string>>(new Set()); // Start with all clusters collapsed
|
||||
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);
|
||||
|
||||
// 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();
|
||||
}, []);
|
||||
|
||||
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 clusters collapsed by default (expandedClusters 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 toggleCluster = (clusterId: string, event?: React.MouseEvent) => {
|
||||
// Prevent scroll jump by storing and restoring scroll position
|
||||
const scrollY = window.scrollY;
|
||||
|
||||
setExpandedClusters(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(clusterId)) {
|
||||
newSet.delete(clusterId);
|
||||
} else {
|
||||
newSet.add(clusterId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
|
||||
// Use requestAnimationFrame to restore scroll position after state update
|
||||
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.clusters.length === 0 &&
|
||||
data.unassigned.applications.length === 0 &&
|
||||
data.unassigned.platforms.length === 0
|
||||
) : true;
|
||||
|
||||
const ClusterBlock = ({ clusterData, isUnassigned = false }: { clusterData: TeamDashboardCluster; isUnassigned?: boolean }) => {
|
||||
const clusterId = clusterData.cluster?.objectId || 'unassigned';
|
||||
const isExpanded = expandedClusters.has(clusterId);
|
||||
const clusterName = isUnassigned ? 'Nog niet toegekend' : (clusterData.cluster?.name || 'Onbekend');
|
||||
|
||||
// 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);
|
||||
|
||||
// Use pre-calculated min/max from backend (sum of all min/max FTE values)
|
||||
const minFTE = clusterData.minEffort ?? 0;
|
||||
const maxFTE = clusterData.maxEffort ?? 0;
|
||||
|
||||
// Calculate application type distribution
|
||||
const byApplicationType: Record<string, number> = {};
|
||||
clusterData.applications.forEach(app => {
|
||||
const appType = app.applicationType?.name || 'Niet ingesteld';
|
||||
byApplicationType[appType] = (byApplicationType[appType] || 0) + 1;
|
||||
});
|
||||
clusterData.platforms.forEach(platformWithWorkloads => {
|
||||
const platformType = platformWithWorkloads.platform.applicationType?.name || 'Niet ingesteld';
|
||||
byApplicationType[platformType] = (byApplicationType[platformType] || 0) + 1;
|
||||
platformWithWorkloads.workloads.forEach(workload => {
|
||||
const workloadType = workload.applicationType?.name || 'Niet ingesteld';
|
||||
byApplicationType[workloadType] = (byApplicationType[workloadType] || 0) + 1;
|
||||
});
|
||||
});
|
||||
|
||||
// Sort applications based on selected sort option
|
||||
const sortedApplications = [...clusterData.applications].sort((a, b) => {
|
||||
if (sortOption === 'alphabetical') {
|
||||
return a.name.localeCompare(b.name, 'nl', { sensitivity: 'base' });
|
||||
} else {
|
||||
// Sort by FTE descending (use override if present, otherwise calculated)
|
||||
const aFTE = getEffectiveFTE(a);
|
||||
const bFTE = getEffectiveFTE(b);
|
||||
return bFTE - aFTE;
|
||||
}
|
||||
});
|
||||
|
||||
// Sort platforms based on selected sort option
|
||||
const sortedPlatforms = [...clusterData.platforms].sort((a, b) => {
|
||||
if (sortOption === 'alphabetical') {
|
||||
return a.platform.name.localeCompare(b.platform.name, 'nl', { sensitivity: 'base' });
|
||||
} else {
|
||||
// Sort by total FTE descending
|
||||
return b.totalEffort - a.totalEffort;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="bg-gray-50 rounded-lg shadow-sm border border-gray-200 mb-4" style={{ overflow: 'visible' }}>
|
||||
<button
|
||||
onClick={() => toggleCluster(clusterId)}
|
||||
className="w-full px-6 py-4 hover:bg-gray-50 transition-colors text-left"
|
||||
style={{ overflow: 'visible' }}
|
||||
>
|
||||
{/* First row: Cluster name and expand icon */}
|
||||
<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 flex-1">{clusterName}</h3>
|
||||
</div>
|
||||
|
||||
{/* Second row: KPIs - all horizontally aligned */}
|
||||
<div className="mt-3 ml-9 flex flex-wrap items-stretch gap-4">
|
||||
{/* FTE: Total and Min-Max range - most important KPI first - with highlight */}
|
||||
<div className="bg-gradient-to-br from-emerald-50 to-teal-50 border border-emerald-200 rounded-xl px-4 py-3 shadow-sm flex flex-col justify-center w-[180px] flex-shrink-0">
|
||||
<div className="text-xs text-emerald-700 font-semibold flex items-center gap-1.5">
|
||||
<svg className="w-4 h-4" 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>
|
||||
FTE Totaal
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-emerald-800">{clusterData.totalEffort.toFixed(2)}</div>
|
||||
<div className="text-[10px] text-emerald-600 font-medium mt-1">
|
||||
Bandbreedte:
|
||||
</div>
|
||||
<div className="text-xs text-emerald-600 font-medium">
|
||||
{minFTE.toFixed(2)} - {maxFTE.toFixed(2)} FTE
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Applicatie count with type distribution */}
|
||||
<div className="flex flex-col justify-center">
|
||||
<div className="text-xs text-gray-500 font-semibold flex items-center gap-1.5">
|
||||
<svg className="w-4 h-4" 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>
|
||||
Applicaties
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-gray-900">{clusterData.applicationCount}</div>
|
||||
{Object.keys(byApplicationType).length > 0 && (
|
||||
<div className="text-xs text-gray-500 mt-1 grid grid-cols-2 gap-x-4 gap-y-0.5">
|
||||
{Object.entries(byApplicationType)
|
||||
.sort((a, b) => {
|
||||
// Sort "Niet ingesteld" to the end
|
||||
if (a[0] === 'Niet ingesteld') return 1;
|
||||
if (b[0] === 'Niet ingesteld') return -1;
|
||||
return a[0].localeCompare(b[0], 'nl', { sensitivity: 'base' });
|
||||
})
|
||||
.map(([type, count]) => {
|
||||
// Color coding for application types
|
||||
const typeColors: Record<string, string> = {
|
||||
'Applicatie': 'bg-blue-400',
|
||||
'Platform': 'bg-purple-400',
|
||||
'Workload': 'bg-orange-400',
|
||||
'Connected Device': 'bg-cyan-400',
|
||||
'Niet ingesteld': 'bg-gray-300',
|
||||
};
|
||||
const dotColor = typeColors[type] || 'bg-gray-400';
|
||||
|
||||
return (
|
||||
<div key={type} className="flex items-center gap-1.5">
|
||||
<span className={`w-2 h-2 rounded-full ${dotColor} flex-shrink-0`}></span>
|
||||
<span className="truncate">{type}: <span className="font-medium text-gray-700">{count}</span></span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Governance Model Distribution - right aligned */}
|
||||
<div className="flex-1 flex flex-col justify-center" style={{ position: 'relative', zIndex: 10, overflow: 'visible' }}>
|
||||
<div className="text-xs font-semibold text-gray-500 text-right mb-1.5 flex items-center justify-end gap-1">
|
||||
Verdeling per regiemodel
|
||||
<span className="text-gray-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" */}
|
||||
{(() => {
|
||||
// Get all governance models, sort alphabetically, add "Niet ingesteld" at the end
|
||||
const allModels = [
|
||||
...governanceModels
|
||||
.map(g => g.name)
|
||||
.sort((a, b) => a.localeCompare(b, 'nl', { sensitivity: 'base' })),
|
||||
'Niet ingesteld'
|
||||
];
|
||||
|
||||
// Color schemes based on the model name/key
|
||||
const getColorScheme = (name: string): { bg: string; text: string } => {
|
||||
if (name.includes('Regiemodel A')) return { bg: '#20556B', text: '#FFFFFF' };
|
||||
if (name.includes('Regiemodel B+') || name.includes('B+')) return { bg: '#286B86', text: '#FFFFFF' };
|
||||
if (name.includes('Regiemodel B')) return { bg: '#286B86', text: '#FFFFFF' };
|
||||
if (name.includes('Regiemodel C')) return { bg: '#81CBF2', text: '#20556B' };
|
||||
if (name.includes('Regiemodel D')) return { bg: '#F5A733', text: '#FFFFFF' };
|
||||
if (name.includes('Regiemodel E')) return { bg: '#E95053', text: '#FFFFFF' };
|
||||
if (name === 'Niet ingesteld') return { bg: '#E5E7EB', text: '#9CA3AF' };
|
||||
return { bg: '#6B7280', text: '#FFFFFF' }; // Default gray
|
||||
};
|
||||
|
||||
// Get short label from model name
|
||||
const getShortLabel = (name: string): string => {
|
||||
if (name === 'Niet ingesteld') return '?';
|
||||
// Extract letter(s) after "Regiemodel " or use first char
|
||||
const match = name.match(/Regiemodel\s+(.+)/i);
|
||||
return match ? match[1] : name.charAt(0);
|
||||
};
|
||||
|
||||
return allModels;
|
||||
})()
|
||||
.map((govModel) => {
|
||||
const count = clusterData.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: '#E5E7EB', text: '#9CA3AF' };
|
||||
return { bg: '#6B7280', text: '#FFFFFF' }; // Default gray
|
||||
})();
|
||||
// Short label: extract letter(s) after "Regiemodel " or use "?"
|
||||
const shortLabel = govModel === 'Niet ingesteld'
|
||||
? '?'
|
||||
: (govModel.match(/Regiemodel\s+(.+)/i)?.[1] || govModel.charAt(0));
|
||||
// Find governance model details from fetched data
|
||||
const govModelData = governanceModels.find(g => g.name === govModel);
|
||||
const hoverKey = `${clusterId}-${govModel}`;
|
||||
const isHovered = hoveredGovModel === hoverKey;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={govModel}
|
||||
className={`rounded-xl py-2 shadow-sm hover:shadow-lg transition-all duration-200 w-[48px] text-center cursor-pointer ${count === 0 ? 'opacity-50 hover:opacity-70' : ''}`}
|
||||
style={{
|
||||
backgroundColor: colors.bg,
|
||||
color: colors.text,
|
||||
position: isHovered ? 'relative' : 'static',
|
||||
zIndex: isHovered ? 9999 : 'auto'
|
||||
}}
|
||||
onMouseEnter={() => setHoveredGovModel(hoverKey)}
|
||||
onMouseLeave={() => setHoveredGovModel(null)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="text-[10px] font-bold uppercase tracking-wider" style={{ opacity: 0.9 }}>
|
||||
{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"
|
||||
style={{
|
||||
pointerEvents: 'auto',
|
||||
zIndex: 99999,
|
||||
backgroundColor: '#ffffff',
|
||||
opacity: 1
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 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>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="border-t border-gray-200 px-6 py-4">
|
||||
{clusterData.applications.length === 0 && clusterData.platforms.length === 0 ? (
|
||||
<p className="text-sm text-gray-500">Geen applicaties in dit cluster</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{/* Platforms with Workloads - shown first */}
|
||||
{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;
|
||||
const platformMinFTE = platform.overrideFTE !== null && platform.overrideFTE !== undefined
|
||||
? platform.overrideFTE
|
||||
: (platform.minFTE ?? platform.requiredEffortApplicationManagement ?? 0);
|
||||
const platformMaxFTE = platform.overrideFTE !== null && platform.overrideFTE !== undefined
|
||||
? platform.overrideFTE
|
||||
: (platform.maxFTE ?? platform.requiredEffortApplicationManagement ?? 0);
|
||||
// Calculate total min/max including workloads
|
||||
const totalMinFTE = platformMinFTE + platformWithWorkloads.workloads.reduce((sum, w) => {
|
||||
return sum + (w.overrideFTE ?? w.minFTE ?? w.requiredEffortApplicationManagement ?? 0);
|
||||
}, 0);
|
||||
const totalMaxFTE = platformMaxFTE + platformWithWorkloads.workloads.reduce((sum, w) => {
|
||||
return sum + (w.overrideFTE ?? w.maxFTE ?? w.requiredEffortApplicationManagement ?? 0);
|
||||
}, 0);
|
||||
|
||||
return (
|
||||
<div key={platformId} className="border border-blue-200 rounded-lg bg-blue-50 overflow-hidden flex">
|
||||
{/* Governance Model indicator */}
|
||||
<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">
|
||||
{/* Platform header */}
|
||||
<div className="flex items-center">
|
||||
{hasWorkloads && (
|
||||
<button
|
||||
onClick={(e) => togglePlatform(platformId, e)}
|
||||
className="p-2 hover:bg-blue-100 transition-colors flex-shrink-0"
|
||||
title={isPlatformExpanded ? 'Inklappen' : 'Uitklappen'}
|
||||
>
|
||||
<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={`/applications/${platformId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`flex-1 p-3 hover:bg-blue-100 transition-colors ${!hasWorkloads ? 'rounded-r-lg' : ''}`}
|
||||
>
|
||||
<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">{platformWithWorkloads.platform.name}</div>
|
||||
<span className="text-xs font-semibold text-blue-700 bg-blue-200 px-2 py-0.5 rounded">
|
||||
Platform
|
||||
</span>
|
||||
{platform.applicationManagementHosting?.name && (
|
||||
<span className="text-xs px-2 py-0.5 bg-blue-100 text-blue-700 rounded-full">
|
||||
{platform.applicationManagementHosting.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">{platformWithWorkloads.platform.key}</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
{(() => {
|
||||
const platformHasOverride = platform.overrideFTE !== null && platform.overrideFTE !== undefined;
|
||||
const platformCalculated = platform.requiredEffortApplicationManagement || 0;
|
||||
const workloadsCalculated = platformWithWorkloads.workloads.reduce((sum, w) =>
|
||||
sum + (w.requiredEffortApplicationManagement || 0), 0
|
||||
);
|
||||
const totalCalculated = platformCalculated + workloadsCalculated;
|
||||
const hasAnyOverride = platformHasOverride || platformWithWorkloads.workloads.some(w =>
|
||||
w.overrideFTE !== null && w.overrideFTE !== undefined
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{platformWithWorkloads.totalEffort.toFixed(2)} FTE
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
{totalMinFTE.toFixed(2)} - {totalMaxFTE.toFixed(2)}
|
||||
</div>
|
||||
{hasAnyOverride && (
|
||||
<div className="text-xs text-gray-400 mt-1">
|
||||
(berekend: {totalCalculated.toFixed(2)})
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
Platform: {platformWithWorkloads.platformEffort.toFixed(2)} FTE
|
||||
{platformHasOverride && platformCalculated !== null && (
|
||||
<span className="text-gray-400"> (berekend: {platformCalculated.toFixed(2)})</span>
|
||||
)}
|
||||
{hasWorkloads && (
|
||||
<> + Workloads: {platformWithWorkloads.workloadsEffort.toFixed(2)} FTE</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Workloads list */}
|
||||
{hasWorkloads && isPlatformExpanded && (
|
||||
<div className="border-t border-blue-200 bg-white rounded-br-lg">
|
||||
<div className="px-3 py-2 bg-blue-100 border-b border-blue-200">
|
||||
<div className="text-xs font-medium text-blue-700">
|
||||
Workloads ({platformWithWorkloads.workloads.length})
|
||||
</div>
|
||||
</div>
|
||||
<div className="divide-y divide-gray-200">
|
||||
{[...platformWithWorkloads.workloads]
|
||||
.sort((a, b) => {
|
||||
if (sortOption === 'alphabetical') {
|
||||
return a.name.localeCompare(b.name, 'nl', { sensitivity: 'base' });
|
||||
} else {
|
||||
// Sort by FTE descending (use override if present, otherwise calculated)
|
||||
const wlEffectiveFTE = (wl: typeof a) => wl.overrideFTE !== null && wl.overrideFTE !== undefined ? wl.overrideFTE : (wl.requiredEffortApplicationManagement || 0);
|
||||
const aFTE = wlEffectiveFTE(a);
|
||||
const bFTE = wlEffectiveFTE(b);
|
||||
return bFTE - aFTE;
|
||||
}
|
||||
})
|
||||
.map((workload) => {
|
||||
const workloadGovStyle = getGovernanceModelStyle(workload.governanceModel?.name);
|
||||
const workloadType = workload.applicationType?.name || 'Workload';
|
||||
const workloadHosting = workload.applicationManagementHosting?.name;
|
||||
const workloadEffectiveFTE = workload.overrideFTE !== null && workload.overrideFTE !== undefined
|
||||
? workload.overrideFTE
|
||||
: workload.requiredEffortApplicationManagement;
|
||||
const workloadMinFTE = workload.overrideFTE ?? workload.minFTE ?? workload.requiredEffortApplicationManagement ?? 0;
|
||||
const workloadMaxFTE = workload.overrideFTE ?? workload.maxFTE ?? workload.requiredEffortApplicationManagement ?? 0;
|
||||
|
||||
return (
|
||||
<div key={workload.id} className="flex items-stretch">
|
||||
{/* Governance Model indicator for workload */}
|
||||
<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 }}
|
||||
title={workload.governanceModel?.name || 'Niet ingesteld'}
|
||||
>
|
||||
{workloadGovStyle.letter}
|
||||
</div>
|
||||
<Link
|
||||
to={`/applications/${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 className="flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium text-gray-700">{workload.name}</span>
|
||||
<span className="text-[10px] px-1.5 py-0.5 bg-gray-100 text-gray-500 rounded">
|
||||
{workloadType}
|
||||
</span>
|
||||
{workloadHosting && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 bg-blue-50 text-blue-600 rounded">
|
||||
{workloadHosting}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">{workload.key}</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
{workloadEffectiveFTE !== null && workloadEffectiveFTE !== undefined ? (
|
||||
<div>
|
||||
<div className="text-xs font-medium text-gray-600">
|
||||
{workloadEffectiveFTE.toFixed(2)} FTE
|
||||
</div>
|
||||
<div className="text-[10px] text-gray-400">
|
||||
{workloadMinFTE.toFixed(2)} - {workloadMaxFTE.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-gray-400">Niet berekend</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Regular applications - shown after platforms */}
|
||||
{sortedApplications.map((app) => {
|
||||
const govStyle = getGovernanceModelStyle(app.governanceModel?.name);
|
||||
const appType = app.applicationType?.name || 'Niet ingesteld';
|
||||
const appHosting = app.applicationManagementHosting?.name;
|
||||
const effectiveFTE = app.overrideFTE !== null && app.overrideFTE !== undefined
|
||||
? app.overrideFTE
|
||||
: app.requiredEffortApplicationManagement;
|
||||
const appMinFTE = app.overrideFTE !== null && app.overrideFTE !== undefined
|
||||
? app.overrideFTE
|
||||
: (app.minFTE ?? app.requiredEffortApplicationManagement ?? 0);
|
||||
const appMaxFTE = app.overrideFTE !== null && app.overrideFTE !== undefined
|
||||
? app.overrideFTE
|
||||
: (app.maxFTE ?? app.requiredEffortApplicationManagement ?? 0);
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={app.id}
|
||||
to={`/applications/${app.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-stretch bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors overflow-hidden"
|
||||
>
|
||||
{/* Governance Model indicator */}
|
||||
<div
|
||||
className="w-10 flex-shrink-0 flex items-center justify-center font-bold text-sm"
|
||||
style={{ backgroundColor: govStyle.bg, color: govStyle.text }}
|
||||
title={app.governanceModel?.name || 'Niet ingesteld'}
|
||||
>
|
||||
{govStyle.letter}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 p-3 flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium text-gray-900">{app.name}</span>
|
||||
<span className="text-xs px-2 py-0.5 bg-gray-200 text-gray-600 rounded-full">
|
||||
{appType}
|
||||
</span>
|
||||
{appHosting && (
|
||||
<span className="text-xs px-2 py-0.5 bg-blue-100 text-blue-700 rounded-full">
|
||||
{appHosting}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">{app.key}</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
{effectiveFTE !== null && effectiveFTE !== undefined ? (
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{effectiveFTE.toFixed(2)} FTE
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
{appMinFTE.toFixed(2)} - {appMaxFTE.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-gray-400">Niet berekend</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</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 Application Cluster
|
||||
</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>
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{/* Clusters */}
|
||||
{!dataLoading && data && data.clusters.length > 0 && (
|
||||
<div className="mb-8">
|
||||
{data.clusters.map((clusterData) => (
|
||||
<ClusterBlock key={clusterData.cluster?.objectId || 'unknown'} clusterData={clusterData} />
|
||||
))}
|
||||
</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">
|
||||
<ClusterBlock
|
||||
isUnassigned={true}
|
||||
clusterData={{
|
||||
cluster: null,
|
||||
applications: data.unassigned.applications,
|
||||
platforms: data.unassigned.platforms,
|
||||
totalEffort: data.unassigned.totalEffort,
|
||||
applicationCount: data.unassigned.applicationCount,
|
||||
byGovernanceModel: data.unassigned.byGovernanceModel,
|
||||
}}
|
||||
/>
|
||||
<div className="px-6 pb-4">
|
||||
<p className="text-sm text-yellow-700">
|
||||
Deze applicaties zijn nog niet toegekend aan een cluster.
|
||||
</p>
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user