Improve Team-indeling dashboard UI and cache invalidation

- Replace 'TEAM' label with Type attribute (Business/Enabling/Staf) in team blocks
- Make Type labels larger (text-sm) and brighter colors
- Make SUBTEAM label less bright (indigo-300) and smaller (text-[10px])
- Add 'FTE' suffix to bandbreedte values in header and application blocks
- Add Platform and Connected Device labels to application blocks
- Show Platform FTE and Workloads FTE separately in Platform blocks
- Add spacing between Regiemodel letter and count value
- Add cache invalidation for Team Dashboard when applications are updated
- Enrich team references with Type attribute in getSubteamToTeamMapping
This commit is contained in:
2026-01-10 02:16:55 +01:00
parent ea1c84262c
commit ca21b9538d
54 changed files with 13444 additions and 1789 deletions

View File

@@ -0,0 +1,620 @@
import { useEffect, useState } from 'react';
import { useParams, Link } from 'react-router-dom';
import { clsx } from 'clsx';
import {
getApplicationById,
getConfig,
getRelatedObjects,
RelatedObject,
} from '../services/api';
import { StatusBadge, BusinessImportanceBadge } from './ApplicationList';
import { EffortDisplay } from './EffortDisplay';
import { useEffortCalculation, getEffectiveFte } from '../hooks/useEffortCalculation';
import type { ApplicationDetails } from '../types';
// Related objects configuration
interface RelatedObjectConfig {
objectType: string;
title: string;
icon: React.ReactNode;
attributes: string[];
columns: { key: string; label: string; isName?: boolean }[];
colorScheme: 'blue' | 'green' | 'orange' | 'purple' | 'cyan';
}
const RELATED_OBJECTS_CONFIG: RelatedObjectConfig[] = [
{
objectType: 'Server',
title: 'Servers',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
</svg>
),
attributes: ['Name', 'Status', 'State'],
columns: [
{ key: 'Name', label: 'Naam', isName: true },
{ key: 'Status', label: 'Status' },
{ key: 'State', label: 'State' },
],
colorScheme: 'blue',
},
{
objectType: 'AzureSubscription',
title: 'Azure Subscriptions',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z" />
</svg>
),
attributes: ['Name', 'Status'],
columns: [
{ key: 'Name', label: 'Naam', isName: true },
{ key: 'Status', label: 'Status' },
],
colorScheme: 'cyan',
},
{
objectType: 'Certificate',
title: 'Certificaten',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
),
attributes: ['Name', 'Status', 'Expiry Date', 'Autorenew', 'Requester', 'Certificate Owner', 'IT Operations Team', 'Application Management'],
columns: [
{ key: 'Name', label: 'Naam', isName: true },
{ key: 'Status', label: 'Status' },
{ key: 'Expiry Date', label: 'Vervaldatum' },
{ key: 'Autorenew', label: 'Auto-renew' },
{ key: 'Certificate Owner', label: 'Eigenaar' },
],
colorScheme: 'orange',
},
{
objectType: 'Connection',
title: 'Connecties',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
),
attributes: ['Name', 'Source', 'Target', 'Type', 'Protocol'],
columns: [
{ key: 'Name', label: 'Naam', isName: true },
{ key: 'Source', label: 'Bron' },
{ key: 'Target', label: 'Doel' },
{ key: 'Type', label: 'Type' },
{ key: 'Protocol', label: 'Protocol' },
],
colorScheme: 'purple',
},
];
const COLOR_SCHEMES = {
blue: {
header: 'bg-blue-50',
icon: 'text-blue-600',
badge: 'bg-blue-100 text-blue-700',
border: 'border-blue-200',
},
green: {
header: 'bg-green-50',
icon: 'text-green-600',
badge: 'bg-green-100 text-green-700',
border: 'border-green-200',
},
orange: {
header: 'bg-orange-50',
icon: 'text-orange-600',
badge: 'bg-orange-100 text-orange-700',
border: 'border-orange-200',
},
purple: {
header: 'bg-purple-50',
icon: 'text-purple-600',
badge: 'bg-purple-100 text-purple-700',
border: 'border-purple-200',
},
cyan: {
header: 'bg-cyan-50',
icon: 'text-cyan-600',
badge: 'bg-cyan-100 text-cyan-700',
border: 'border-cyan-200',
},
};
export default function ApplicationInfo() {
const { id } = useParams<{ id: string }>();
const [application, setApplication] = useState<ApplicationDetails | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [jiraHost, setJiraHost] = useState<string>('');
// Use centralized effort calculation hook
const { calculatedFte, breakdown: effortBreakdown } = useEffortCalculation({
application,
});
// Related objects state
const [relatedObjects, setRelatedObjects] = useState<Map<string, { objects: RelatedObject[]; loading: boolean; error: string | null }>>(new Map());
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set(['Server', 'Certificate'])); // Default expanded
useEffect(() => {
async function fetchData() {
if (!id) return;
setLoading(true);
setError(null);
try {
const [app, config] = await Promise.all([
getApplicationById(id),
getConfig(),
]);
setApplication(app);
setJiraHost(config.jiraHost);
// Note: Effort calculation is handled automatically by useEffortCalculation hook
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load application');
} finally {
setLoading(false);
}
}
fetchData();
}, [id]);
// Set page title
useEffect(() => {
if (application) {
document.title = `${application.name} | Zuyderland CMDB`;
}
return () => {
document.title = 'Zuyderland CMDB';
};
}, [application]);
// Fetch related objects when application is loaded
useEffect(() => {
if (!id || !application) return;
// Initialize loading state for all object types
const initialState = new Map<string, { objects: RelatedObject[]; loading: boolean; error: string | null }>();
RELATED_OBJECTS_CONFIG.forEach(config => {
initialState.set(config.objectType, { objects: [], loading: true, error: null });
});
setRelatedObjects(initialState);
// Fetch each object type in parallel
RELATED_OBJECTS_CONFIG.forEach(async (config) => {
try {
const result = await getRelatedObjects(id, config.objectType, config.attributes);
setRelatedObjects(prev => {
const newMap = new Map(prev);
newMap.set(config.objectType, { objects: result?.objects || [], loading: false, error: null });
return newMap;
});
} catch (err) {
setRelatedObjects(prev => {
const newMap = new Map(prev);
newMap.set(config.objectType, {
objects: [],
loading: false,
error: err instanceof Error ? err.message : 'Failed to load'
});
return newMap;
});
}
});
}, [id, application]);
const toggleSection = (objectType: string) => {
setExpandedSections(prev => {
const newSet = new Set(prev);
if (newSet.has(objectType)) {
newSet.delete(objectType);
} else {
newSet.add(objectType);
}
return newSet;
});
};
if (loading) {
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>
);
}
if (error || !application) {
return (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-red-700">
{error || 'Application not found'}
</div>
);
}
return (
<div className="space-y-6">
{/* Back navigation */}
<div className="flex justify-between items-center">
<Link
to="/application/overview"
className="flex items-center text-gray-600 hover:text-gray-900"
>
<svg
className="w-5 h-5 mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 19l-7-7 7-7"
/>
</svg>
Terug naar overzicht
</Link>
</div>
{/* Header with application name and quick actions */}
<div className="bg-white rounded-lg border border-gray-200 p-6">
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h1 className="text-2xl font-bold text-gray-900">{application.name}</h1>
<StatusBadge status={application.status} />
</div>
<div className="flex flex-wrap items-center gap-3 text-sm text-gray-500">
<span className="font-mono bg-gray-100 px-2 py-0.5 rounded">{application.key}</span>
{application.applicationType && (
<span className="px-2 py-0.5 bg-purple-100 text-purple-700 rounded">
{application.applicationType.name}
</span>
)}
{application.hostingType && (
<span className="px-2 py-0.5 bg-blue-100 text-blue-700 rounded">
{application.hostingType.name}
</span>
)}
</div>
</div>
{/* Quick action buttons */}
<div className="flex flex-wrap gap-2">
{jiraHost && application.key && (
<a
href={`${jiraHost}/secure/insight/assets/${application.key}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors text-sm font-medium"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
Open in Jira
</a>
)}
<Link
to={`/application/${id}/edit`}
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors text-sm font-medium"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
Bewerken
</Link>
</div>
</div>
{/* Description */}
{application.description && (
<div className="mt-4 pt-4 border-t border-gray-100">
<p className="text-gray-600">{application.description}</p>
</div>
)}
</div>
{/* Main info grid */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Left column - Basic info */}
<div className="bg-white rounded-lg border border-gray-200">
<div className="px-6 py-4 border-b border-gray-200 bg-gray-50">
<h3 className="text-lg font-medium text-gray-900">Basis informatie</h3>
</div>
<div className="p-6 space-y-4">
<InfoRow label="Search Reference" value={application.searchReference} />
<InfoRow label="Leverancier/Product" value={application.supplierProduct} />
<InfoRow label="Organisatie" value={application.organisation} />
{application.technischeArchitectuur && application.technischeArchitectuur.trim() !== '' && (
<div>
<label className="block text-sm font-medium text-gray-500 mb-1">Technische Architectuur</label>
<a
href={`${application.technischeArchitectuur}${application.technischeArchitectuur.includes('?') ? '&' : '?'}csf=1&web=1`}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 hover:underline inline-flex items-center gap-1"
>
Document openen
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
</div>
)}
</div>
</div>
{/* Right column - Business info */}
<div className="bg-white rounded-lg border border-gray-200">
<div className="px-6 py-4 border-b border-gray-200 bg-gray-50">
<h3 className="text-lg font-medium text-gray-900">Business informatie</h3>
</div>
<div className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-500 mb-1">Business Importance</label>
<BusinessImportanceBadge importance={application.businessImportance} />
</div>
<InfoRow label="Business Impact Analyse" value={application.businessImpactAnalyse?.name} />
<InfoRow label="Business Owner" value={application.businessOwner} />
<InfoRow label="System Owner" value={application.systemOwner} />
</div>
</div>
</div>
{/* Management section */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Governance */}
<div className="bg-white rounded-lg border border-gray-200">
<div className="px-6 py-4 border-b border-gray-200 bg-gray-50">
<h3 className="text-lg font-medium text-gray-900">Governance & Management</h3>
</div>
<div className="p-6 space-y-4">
<InfoRow label="Regiemodel" value={application.governanceModel?.name} />
<InfoRow label="Subteam" value={application.applicationSubteam?.name} />
<InfoRow label="Team" value={application.applicationTeam?.name} />
<InfoRow label="Application Management - Hosting" value={application.applicationManagementHosting?.name} />
<InfoRow label="Application Management - TAM" value={application.applicationManagementTAM?.name} />
</div>
</div>
{/* Contacts */}
<div className="bg-white rounded-lg border border-gray-200">
<div className="px-6 py-4 border-b border-gray-200 bg-gray-50">
<h3 className="text-lg font-medium text-gray-900">Contactpersonen</h3>
</div>
<div className="p-6 space-y-4">
<InfoRow label="Functioneel Beheer" value={application.functionalApplicationManagement} />
<InfoRow label="Technisch Applicatiebeheer" value={application.technicalApplicationManagement} />
<InfoRow
label="Contactpersonen TAB"
value={(() => {
const primary = application.technicalApplicationManagementPrimary?.trim();
const secondary = application.technicalApplicationManagementSecondary?.trim();
const parts = [];
if (primary) parts.push(primary);
if (secondary) parts.push(secondary);
return parts.length > 0 ? parts.join(', ') : undefined;
})()}
/>
</div>
</div>
</div>
{/* Classification section */}
<div className="bg-white rounded-lg border border-gray-200">
<div className="px-6 py-4 border-b border-gray-200 bg-gray-50">
<h3 className="text-lg font-medium text-gray-900">Classificatie</h3>
</div>
<div className="p-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<InfoRow label="Dynamics Factor" value={application.dynamicsFactor?.name} />
<InfoRow label="Complexity Factor" value={application.complexityFactor?.name} />
<InfoRow label="Number of Users" value={application.numberOfUsers?.name} />
</div>
{/* FTE - Benodigde inspanning applicatiemanagement */}
<div className="mt-6 pt-6 border-t border-gray-100">
<label className="block text-sm font-medium text-gray-700 mb-3">
Benodigde inspanning applicatiemanagement
</label>
<div className="w-full border border-gray-300 rounded-lg px-3 py-2 bg-gray-50">
<EffortDisplay
effectiveFte={getEffectiveFte(calculatedFte, application.overrideFTE, application.requiredEffortApplicationManagement)}
calculatedFte={calculatedFte ?? application.requiredEffortApplicationManagement ?? null}
overrideFte={application.overrideFTE ?? null}
breakdown={effortBreakdown}
isPreview={false}
showDetails={true}
showOverrideInput={false}
/>
</div>
<p className="mt-1 text-xs text-gray-500">
Automatisch berekend op basis van Regiemodel, Application Type, Business Impact Analyse en Hosting (v25)
</p>
</div>
</div>
</div>
{/* Application Functions */}
{application.applicationFunctions && application.applicationFunctions.length > 0 && (
<div className="bg-white rounded-lg border border-gray-200">
<div className="px-6 py-4 border-b border-gray-200 bg-gray-50">
<h3 className="text-lg font-medium text-gray-900">
Applicatiefuncties ({application.applicationFunctions.length})
</h3>
</div>
<div className="p-6">
<div className="flex flex-wrap gap-2">
{application.applicationFunctions.map((func, index) => (
<span
key={func.objectId || index}
className={clsx(
'inline-flex items-center px-3 py-1 rounded-full text-sm',
index === 0
? 'bg-blue-100 text-blue-800 font-medium'
: 'bg-gray-100 text-gray-700'
)}
>
<span className="font-mono text-xs mr-2 opacity-70">{func.key}</span>
{func.name}
</span>
))}
</div>
</div>
</div>
)}
{/* Related Objects Sections */}
<div className="space-y-4">
<h2 className="text-lg font-semibold text-gray-900">Gerelateerde objecten</h2>
{RELATED_OBJECTS_CONFIG.map((config) => {
const data = relatedObjects.get(config.objectType);
const isExpanded = expandedSections.has(config.objectType);
const colors = COLOR_SCHEMES[config.colorScheme];
const objects = data?.objects || [];
const count = objects.length;
const isLoading = data?.loading ?? true;
return (
<div key={config.objectType} className={clsx('bg-white rounded-lg border', colors.border)}>
{/* Header - clickable to expand/collapse */}
<button
onClick={() => toggleSection(config.objectType)}
className={clsx(
'w-full px-6 py-4 flex items-center justify-between',
colors.header,
'hover:opacity-90 transition-opacity'
)}
>
<div className="flex items-center gap-3">
<span className={colors.icon}>{config.icon}</span>
<h3 className="text-lg font-medium text-gray-900">{config.title}</h3>
{isLoading ? (
<div className="animate-spin rounded-full h-4 w-4 border-2 border-gray-300 border-t-gray-600" />
) : (
<span className={clsx('px-2 py-0.5 rounded-full text-xs font-medium', colors.badge)}>
{count}
</span>
)}
</div>
<svg
className={clsx(
'w-5 h-5 text-gray-500 transition-transform',
isExpanded && 'transform 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>
{/* Content */}
{isExpanded && (
<div className="border-t border-gray-100">
{isLoading ? (
<div className="p-6 text-center">
<div className="animate-spin rounded-full h-6 w-6 border-2 border-gray-300 border-t-blue-600 mx-auto" />
<p className="text-gray-500 text-sm mt-2">Laden...</p>
</div>
) : data?.error ? (
<div className="p-6 text-center text-red-600">
<p className="text-sm">{data.error}</p>
</div>
) : count === 0 ? (
<div className="p-6 text-center text-gray-500">
<p className="text-sm">Geen {config.title.toLowerCase()} gevonden</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
{config.columns.map((col) => (
<th
key={col.key}
className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
{col.label}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{objects.map((obj) => (
<tr key={obj.id} className="hover:bg-gray-50">
{config.columns.map((col) => (
<td key={col.key} className="px-4 py-3 text-sm text-gray-900">
{col.isName && jiraHost ? (
<a
href={`${jiraHost}/secure/insight/assets/${obj.key}`}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 hover:underline inline-flex items-center gap-1"
>
{obj.attributes[col.key] || obj.name || '-'}
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
) : (
obj.attributes[col.key] || '-'
)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</div>
);
})}
</div>
{/* Call to action */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h3 className="text-lg font-medium text-blue-900">Classificatie aanpassen?</h3>
<p className="text-blue-700 text-sm mt-1">
Bewerk applicatiefuncties, classificatie en regiemodel met AI-ondersteuning.
</p>
</div>
<Link
to={`/application/${id}/edit`}
className="inline-flex items-center gap-2 px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors font-medium whitespace-nowrap"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
Bewerken
</Link>
</div>
</div>
</div>
);
}
// Helper component for displaying info rows
function InfoRow({ label, value }: { label: string; value?: string | null }) {
return (
<div>
<label className="block text-sm font-medium text-gray-500 mb-1">{label}</label>
<p className="text-gray-900">{value || '-'}</p>
</div>
);
}