Add database adapter system, production deployment configs, and new dashboard components

- Add PostgreSQL and SQLite database adapters with factory pattern
- Add migration script for SQLite to PostgreSQL
- Add production Dockerfiles and docker-compose configs
- Add deployment documentation and scripts
- Add BIA sync dashboard and matching service
- Add data completeness configuration and components
- Add new dashboard components (BusinessImportanceComparison, ComplexityDynamics, etc.)
- Update various services and routes
- Remove deprecated management-parameters.json and taxonomy files
This commit is contained in:
2026-01-14 00:38:40 +01:00
parent ca21b9538d
commit a7f8301196
73 changed files with 12878 additions and 2003 deletions

View File

@@ -11,7 +11,16 @@ import ConfigurationV25 from './components/ConfigurationV25';
import ReportsDashboard from './components/ReportsDashboard';
import GovernanceAnalysis from './components/GovernanceAnalysis';
import DataModelDashboard from './components/DataModelDashboard';
import TechnicalDebtHeatmap from './components/TechnicalDebtHeatmap';
import LifecyclePipeline from './components/LifecyclePipeline';
import DataCompletenessScore from './components/DataCompletenessScore';
import ZiRADomainCoverage from './components/ZiRADomainCoverage';
import FTEPerZiRADomain from './components/FTEPerZiRADomain';
import ComplexityDynamicsBubbleChart from './components/ComplexityDynamicsBubbleChart';
import FTECalculator from './components/FTECalculator';
import DataCompletenessConfig from './components/DataCompletenessConfig';
import BIASyncDashboard from './components/BIASyncDashboard';
import BusinessImportanceComparison from './components/BusinessImportanceComparison';
import Login from './components/Login';
import { useAuthStore } from './stores/authStore';
@@ -190,7 +199,6 @@ function AppContent() {
{ path: '/app-components', label: 'Dashboard', exact: true },
{ path: '/application/overview', label: 'Overzicht', exact: false },
{ path: '/application/fte-calculator', label: 'FTE Calculator', exact: true },
{ path: '/app-components/fte-config', label: 'FTE Config', exact: true },
],
};
@@ -201,12 +209,38 @@ function AppContent() {
{ path: '/reports', label: 'Overzicht', exact: true },
{ path: '/reports/team-dashboard', label: 'Team-indeling', exact: true },
{ path: '/reports/governance-analysis', label: 'Analyse Regiemodel', exact: true },
{ path: '/reports/data-model', label: 'Datamodel', exact: true },
{ path: '/reports/technical-debt-heatmap', label: 'Technical Debt Heatmap', exact: true },
{ path: '/reports/lifecycle-pipeline', label: 'Lifecycle Pipeline', exact: true },
{ path: '/reports/data-completeness', label: 'Data Completeness Score', exact: true },
{ path: '/reports/zira-domain-coverage', label: 'ZiRA Domain Coverage', exact: true },
{ path: '/reports/fte-per-zira-domain', label: 'FTE per ZiRA Domain', exact: true },
{ path: '/reports/complexity-dynamics-bubble', label: 'Complexity vs Dynamics Bubble Chart', exact: true },
{ path: '/reports/business-importance-comparison', label: 'Business Importance vs BIA', exact: true },
],
};
const appsDropdown: NavDropdown = {
label: 'Apps',
basePath: '/apps',
items: [
{ path: '/apps/bia-sync', label: 'BIA Sync', exact: true },
],
};
const settingsDropdown: NavDropdown = {
label: 'Instellingen',
basePath: '/settings',
items: [
{ path: '/settings/fte-config', label: 'FTE Config', exact: true },
{ path: '/settings/data-model', label: 'Datamodel', exact: true },
{ path: '/settings/data-completeness-config', label: 'Data Completeness Config', exact: true },
],
};
const isAppComponentsActive = location.pathname.startsWith('/app-components') || location.pathname.startsWith('/application');
const isReportsActive = location.pathname.startsWith('/reports');
const isSettingsActive = location.pathname.startsWith('/settings');
const isAppsActive = location.pathname.startsWith('/apps');
const isDashboardActive = location.pathname === '/';
return (
@@ -243,8 +277,14 @@ function AppContent() {
{/* Application Component Dropdown */}
<NavDropdown dropdown={appComponentsDropdown} isActive={isAppComponentsActive} />
{/* Apps Dropdown */}
<NavDropdown dropdown={appsDropdown} isActive={isAppsActive} />
{/* Reports Dropdown */}
<NavDropdown dropdown={reportsDropdown} isActive={isReportsActive} />
{/* Settings Dropdown */}
<NavDropdown dropdown={settingsDropdown} isActive={isSettingsActive} />
</nav>
</div>
@@ -267,21 +307,37 @@ function AppContent() {
{/* Application Component routes */}
<Route path="/app-components" element={<Dashboard />} />
<Route path="/app-components/fte-config" element={<ConfigurationV25 />} />
{/* Reports routes */}
<Route path="/reports" element={<ReportsDashboard />} />
<Route path="/reports/team-dashboard" element={<TeamDashboard />} />
<Route path="/reports/governance-analysis" element={<GovernanceAnalysis />} />
<Route path="/reports/data-model" element={<DataModelDashboard />} />
<Route path="/reports/technical-debt-heatmap" element={<TechnicalDebtHeatmap />} />
<Route path="/reports/lifecycle-pipeline" element={<LifecyclePipeline />} />
<Route path="/reports/data-completeness" element={<DataCompletenessScore />} />
<Route path="/reports/zira-domain-coverage" element={<ZiRADomainCoverage />} />
<Route path="/reports/fte-per-zira-domain" element={<FTEPerZiRADomain />} />
<Route path="/reports/complexity-dynamics-bubble" element={<ComplexityDynamicsBubbleChart />} />
<Route path="/reports/business-importance-comparison" element={<BusinessImportanceComparison />} />
{/* Apps routes */}
<Route path="/apps/bia-sync" element={<BIASyncDashboard />} />
{/* Settings routes */}
<Route path="/settings/fte-config" element={<ConfigurationV25 />} />
<Route path="/settings/data-model" element={<DataModelDashboard />} />
<Route path="/settings/data-completeness-config" element={<DataCompletenessConfig />} />
{/* Legacy redirects for bookmarks - redirect old paths to new ones */}
<Route path="/app-components/overview" element={<Navigate to="/application/overview" replace />} />
<Route path="/app-components/overview/:id" element={<RedirectToApplicationEdit />} />
<Route path="/app-components/fte-config" element={<Navigate to="/settings/fte-config" replace />} />
<Route path="/applications" element={<Navigate to="/application/overview" replace />} />
<Route path="/applications/:id" element={<RedirectToApplicationEdit />} />
<Route path="/reports/data-model" element={<Navigate to="/settings/data-model" replace />} />
<Route path="/reports/bia-sync" element={<Navigate to="/apps/bia-sync" replace />} />
<Route path="/teams" element={<TeamDashboard />} />
<Route path="/configuration" element={<ConfigurationV25 />} />
<Route path="/configuration" element={<Navigate to="/settings/fte-config" replace />} />
</Routes>
</main>
</div>

View File

@@ -140,7 +140,7 @@ export default function ApplicationInfo() {
// 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
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set()); // Default collapsed
useEffect(() => {
async function fetchData() {
@@ -365,6 +365,30 @@ export default function ApplicationInfo() {
<InfoRow label="Business Impact Analyse" value={application.businessImpactAnalyse?.name} />
<InfoRow label="Business Owner" value={application.businessOwner} />
<InfoRow label="System Owner" value={application.systemOwner} />
{application.dataCompletenessPercentage !== undefined && (
<div>
<label className="block text-sm font-medium text-gray-500 mb-1">Data Completeness</label>
<div className="flex items-center gap-3">
<div className="flex-1 min-w-[120px]">
<div className="w-full bg-gray-200 rounded-full h-2.5">
<div
className={`h-2.5 rounded-full transition-all ${
application.dataCompletenessPercentage >= 80
? 'bg-green-500'
: application.dataCompletenessPercentage >= 60
? 'bg-yellow-500'
: 'bg-red-500'
}`}
style={{ width: `${application.dataCompletenessPercentage}%` }}
/>
</div>
</div>
<span className="text-sm font-semibold text-gray-900 min-w-[50px]">
{application.dataCompletenessPercentage.toFixed(1)}%
</span>
</div>
</div>
)}
</div>
</div>
</div>
@@ -463,7 +487,6 @@ export default function ApplicationInfo() {
: 'bg-gray-100 text-gray-700'
)}
>
<span className="font-mono text-xs mr-2 opacity-70">{func.key}</span>
{func.name}
</span>
))}

View File

@@ -394,6 +394,9 @@ export default function ApplicationList() {
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Benodigde inspanning
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Data Completeness
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
@@ -489,6 +492,37 @@ export default function ApplicationList() {
)}
</Link>
</td>
<td className="py-0">
<Link
to={`/application/${app.id}`}
onClick={(e) => handleRowClick(app, index, e)}
className="block px-4 py-3"
>
{app.dataCompletenessPercentage !== undefined ? (
<div className="flex items-center gap-2">
<div className="flex-1 min-w-[60px]">
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className={`h-2 rounded-full transition-all ${
app.dataCompletenessPercentage >= 80
? 'bg-green-500'
: app.dataCompletenessPercentage >= 60
? 'bg-yellow-500'
: 'bg-red-500'
}`}
style={{ width: `${app.dataCompletenessPercentage}%` }}
/>
</div>
</div>
<span className="text-sm font-medium text-gray-700 min-w-[45px]">
{app.dataCompletenessPercentage.toFixed(0)}%
</span>
</div>
) : (
<span className="text-sm text-gray-400">-</span>
)}
</Link>
</td>
</tr>
))}
</tbody>

View File

@@ -0,0 +1,615 @@
import { useEffect, useState, useCallback, useRef } from 'react';
import { Link } from 'react-router-dom';
import { clsx } from 'clsx';
import { getBIAComparison, updateApplication, getBusinessImpactAnalyses } from '../services/api';
import type { BIAComparisonItem, BIAComparisonResponse, ReferenceValue } from '../types';
type MatchStatusFilter = 'all' | 'match' | 'mismatch' | 'not_found' | 'no_excel_bia';
type MatchTypeFilter = 'all' | 'exact' | 'search_reference' | 'fuzzy' | 'none';
export default function BIASyncDashboard() {
const [data, setData] = useState<BIAComparisonResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchText, setSearchText] = useState('');
const [statusFilter, setStatusFilter] = useState<MatchStatusFilter>('all');
const [matchTypeFilter, setMatchTypeFilter] = useState<MatchTypeFilter>('all');
const [savingIds, setSavingIds] = useState<Set<string>>(new Set());
const [businessImpactAnalyses, setBusinessImpactAnalyses] = useState<ReferenceValue[]>([]);
const [expandedMatches, setExpandedMatches] = useState<Set<string>>(new Set());
const [isHeaderFixed, setIsHeaderFixed] = useState(false);
const [headerHeight, setHeaderHeight] = useState(0);
const tableRef = useRef<HTMLTableElement>(null);
const theadRef = useRef<HTMLTableSectionElement>(null);
const fetchData = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [comparisonData, biaData] = await Promise.all([
getBIAComparison(),
getBusinessImpactAnalyses(),
]);
setData(comparisonData);
setBusinessImpactAnalyses(biaData);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load BIA comparison');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
// Make header sticky on scroll
useEffect(() => {
if (!theadRef.current || !tableRef.current || !data) return;
const tableContainer = tableRef.current.closest('.overflow-x-auto');
if (!tableContainer) return;
const handleScroll = () => {
const tableRect = tableRef.current!.getBoundingClientRect();
const containerRect = (tableContainer as HTMLElement).getBoundingClientRect();
// When table header scrolls out of view, make it fixed at the very top
if (tableRect.top < 0 && tableRect.bottom > 50) {
// Only apply fixed positioning if not already fixed (to avoid recalculating widths)
if (!isHeaderFixed) {
// Get all header cells and body cells to match widths
const headerCells = theadRef.current!.querySelectorAll('th');
const firstBodyRow = tableRef.current!.querySelector('tbody tr');
const bodyCells = firstBodyRow?.querySelectorAll('td') || [];
if (headerCells.length > 0) {
// Store header height if not already stored
if (headerHeight === 0) {
const currentHeight = theadRef.current!.getBoundingClientRect().height;
setHeaderHeight(currentHeight);
}
// Measure column widths BEFORE making header fixed
const columnWidths: number[] = [];
headerCells.forEach((headerCell, index) => {
let width: number;
if (bodyCells[index]) {
width = bodyCells[index].getBoundingClientRect().width;
} else {
width = headerCell.getBoundingClientRect().width;
}
columnWidths.push(width);
});
// Use table-layout: fixed to lock column widths
tableRef.current!.style.tableLayout = 'fixed';
// Apply measured widths to header cells
headerCells.forEach((headerCell, index) => {
const width = columnWidths[index];
(headerCell as HTMLElement).style.width = `${width}px`;
(headerCell as HTMLElement).style.minWidth = `${width}px`;
(headerCell as HTMLElement).style.maxWidth = `${width}px`;
});
}
}
// Update position and dimensions
theadRef.current!.style.position = 'fixed';
theadRef.current!.style.top = '0px';
theadRef.current!.style.left = `${containerRect.left}px`;
theadRef.current!.style.width = `${containerRect.width}px`;
theadRef.current!.style.zIndex = '50';
theadRef.current!.style.backgroundColor = '#f9fafb';
theadRef.current!.style.boxShadow = '0 1px 3px 0 rgba(0, 0, 0, 0.1)';
setIsHeaderFixed(true);
} else {
// Reset table layout and column widths when not fixed
if (isHeaderFixed) {
tableRef.current!.style.tableLayout = '';
const headerCells = theadRef.current!.querySelectorAll('th');
headerCells.forEach((cell) => {
(cell as HTMLElement).style.width = '';
(cell as HTMLElement).style.minWidth = '';
(cell as HTMLElement).style.maxWidth = '';
});
}
theadRef.current!.style.position = '';
theadRef.current!.style.top = '';
theadRef.current!.style.left = '';
theadRef.current!.style.width = '';
theadRef.current!.style.zIndex = '';
theadRef.current!.style.backgroundColor = '';
theadRef.current!.style.boxShadow = '';
setIsHeaderFixed(false);
}
};
window.addEventListener('scroll', handleScroll, { passive: true });
window.addEventListener('resize', handleScroll, { passive: true });
if (tableContainer) {
tableContainer.addEventListener('scroll', handleScroll, { passive: true });
}
handleScroll(); // Check initial position
return () => {
window.removeEventListener('scroll', handleScroll);
window.removeEventListener('resize', handleScroll);
if (tableContainer) {
tableContainer.removeEventListener('scroll', handleScroll);
}
};
}, [data]);
const handleSave = async (item: BIAComparisonItem) => {
if (!item.excelBIA) {
return;
}
// Find the ReferenceValue for the Excel BIA letter
// Try to match by first character or by name pattern like "A - ..."
const biaValue = businessImpactAnalyses.find(bia => {
// Try matching first character
const firstChar = bia.name.charAt(0).toUpperCase();
if (firstChar === item.excelBIA && /^[A-F]$/.test(firstChar)) {
return true;
}
// Try matching pattern like "A - ..."
const match = bia.name.match(/^([A-F])/);
if (match && match[1].toUpperCase() === item.excelBIA) {
return true;
}
return false;
});
if (!biaValue) {
alert(`Kon geen Business Impact Analyse vinden voor waarde "${item.excelBIA}"`);
return;
}
if (!confirm(`Weet je zeker dat je de BIA waarde voor "${item.name}" wilt bijwerken naar "${biaValue.name}"?`)) {
return;
}
setSavingIds(prev => new Set(prev).add(item.id));
try {
await updateApplication(item.id, {
businessImpactAnalyse: biaValue,
source: 'MANUAL',
});
// Update only the specific item in the state instead of reloading everything
if (data) {
setData(prevData => {
if (!prevData) return prevData;
const updatedApplications = prevData.applications.map(app => {
if (app.id === item.id) {
// Update the current BIA and match status
const newMatchStatus: BIAComparisonItem['matchStatus'] =
app.excelBIA === item.excelBIA ? 'match' : 'mismatch';
return {
...app,
currentBIA: biaValue,
matchStatus: newMatchStatus,
};
}
return app;
});
// Recalculate summary
const summary = {
total: updatedApplications.length,
matched: updatedApplications.filter(a => a.matchStatus === 'match').length,
mismatched: updatedApplications.filter(a => a.matchStatus === 'mismatch').length,
notFound: updatedApplications.filter(a => a.matchStatus === 'not_found').length,
noExcelBIA: updatedApplications.filter(a => a.matchStatus === 'no_excel_bia').length,
};
return {
applications: updatedApplications,
summary,
};
});
}
} catch (err) {
alert(`Fout bij bijwerken: ${err instanceof Error ? err.message : 'Onbekende fout'}`);
} finally {
setSavingIds(prev => {
const next = new Set(prev);
next.delete(item.id);
return next;
});
}
};
// Filter applications
const filteredApplications = data?.applications.filter(app => {
// Search filter
if (searchText) {
const searchLower = searchText.toLowerCase();
const matchesSearch =
app.name.toLowerCase().includes(searchLower) ||
app.key.toLowerCase().includes(searchLower) ||
(app.searchReference && app.searchReference.toLowerCase().includes(searchLower));
if (!matchesSearch) return false;
}
// Status filter
if (statusFilter !== 'all' && app.matchStatus !== statusFilter) {
return false;
}
// Match type filter
if (matchTypeFilter !== 'all') {
if (matchTypeFilter === 'none' && app.matchType !== null) {
return false;
}
if (matchTypeFilter !== 'none' && app.matchType !== matchTypeFilter) {
return false;
}
}
return true;
}) || [];
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) {
return (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-red-700">
{error}
</div>
);
}
if (!data) {
return null;
}
const getStatusBadgeClass = (status: BIAComparisonItem['matchStatus']) => {
switch (status) {
case 'match':
return 'bg-green-100 text-green-800';
case 'mismatch':
return 'bg-red-100 text-red-800';
case 'not_found':
return 'bg-yellow-100 text-yellow-800';
case 'no_excel_bia':
return 'bg-gray-100 text-gray-800';
default:
return 'bg-gray-100 text-gray-800';
}
};
const getStatusLabel = (status: BIAComparisonItem['matchStatus']) => {
switch (status) {
case 'match':
return 'Match';
case 'mismatch':
return 'Verschil';
case 'not_found':
return 'Niet in CMDB';
case 'no_excel_bia':
return 'Niet in Excel';
default:
return status;
}
};
const getMatchTypeLabel = (matchType: BIAComparisonItem['matchType']) => {
switch (matchType) {
case 'exact':
return 'Exact';
case 'search_reference':
return 'Zoekreferentie';
case 'fuzzy':
return 'Fuzzy';
default:
return '-';
}
};
return (
<div className="space-y-6">
{/* Page header */}
<div>
<h2 className="text-2xl font-bold text-gray-900">BIA Sync Dashboard</h2>
<p className="text-gray-600">
Vergelijk Business Impact Analyse waarden uit Excel met de CMDB
</p>
</div>
{/* Summary cards */}
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
<div className="card p-4">
<div className="text-sm text-gray-500 mb-1">Totaal</div>
<div className="text-2xl font-bold text-gray-900">{data.summary.total}</div>
</div>
<div className="card p-4">
<div className="text-sm text-gray-500 mb-1">Match</div>
<div className="text-2xl font-bold text-green-600">{data.summary.matched}</div>
</div>
<div className="card p-4">
<div className="text-sm text-gray-500 mb-1">Verschil</div>
<div className="text-2xl font-bold text-red-600">{data.summary.mismatched}</div>
</div>
<div className="card p-4">
<div className="text-sm text-gray-500 mb-1">Niet in CMDB</div>
<div className="text-2xl font-bold text-yellow-600">{data.summary.notFound}</div>
</div>
<div className="card p-4">
<div className="text-sm text-gray-500 mb-1">Niet in Excel</div>
<div className="text-2xl font-bold text-gray-600">{data.summary.noExcelBIA}</div>
</div>
</div>
{/* Filters */}
<div className="card p-4">
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex-1">
<input
type="text"
placeholder="Zoek op naam, key of search reference..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as MatchStatusFilter)}
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="all">Alle statussen</option>
<option value="match">Match</option>
<option value="mismatch">Verschil</option>
<option value="not_found">Niet in CMDB</option>
<option value="no_excel_bia">Niet in Excel</option>
</select>
</div>
<div>
<select
value={matchTypeFilter}
onChange={(e) => setMatchTypeFilter(e.target.value as MatchTypeFilter)}
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="all">Alle match types</option>
<option value="exact">Exact</option>
<option value="search_reference">Zoekreferentie</option>
<option value="fuzzy">Fuzzy</option>
<option value="none">Geen match</option>
</select>
</div>
<button
onClick={fetchData}
className="btn btn-secondary flex items-center space-x-2"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
<span>Ververs</span>
</button>
</div>
</div>
{/* Table */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200">
<div className="overflow-x-auto">
{/* Placeholder spacer when header is fixed */}
{isHeaderFixed && headerHeight > 0 && (
<div style={{ height: `${headerHeight}px` }} className="bg-transparent" />
)}
<table ref={tableRef} className="min-w-full divide-y divide-gray-200">
<thead ref={theadRef} className="bg-gray-50 sticky top-0 z-10 shadow-sm">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">
Applicatie
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">
BIA (CMDB)
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">
BIA (Excel)
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">
Match Type
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">
Actie
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{filteredApplications.length === 0 ? (
<tr>
<td colSpan={6} className="px-6 py-4 text-center text-gray-500">
Geen applicaties gevonden
</td>
</tr>
) : (
filteredApplications.map((item) => (
<tr key={item.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div>
<div className="text-sm font-medium text-gray-900">
<Link
to={`/application/${item.id}`}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 hover:underline"
onClick={(e) => e.stopPropagation()}
>
{item.name}
</Link>
</div>
<div className="text-sm text-gray-500">
{item.key}
{item.searchReference && `${item.searchReference}`}
</div>
{item.excelApplicationName && item.excelApplicationName !== item.name && (
<div className="text-xs text-gray-400 mt-1">
Excel: {item.excelApplicationName}
</div>
)}
{item.allMatches && item.allMatches.length > 1 && (
<div className="mt-1">
<button
onClick={() => {
setExpandedMatches(prev => {
const next = new Set(prev);
if (next.has(item.id)) {
next.delete(item.id);
} else {
next.add(item.id);
}
return next;
});
}}
className="text-xs text-blue-600 hover:text-blue-800 hover:underline flex items-center gap-1"
>
<span> {item.allMatches.length} matches gevonden</span>
<svg
className={`w-3 h-3 transition-transform ${expandedMatches.has(item.id) ? '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>
{expandedMatches.has(item.id) && (
<div className="mt-2 p-2 bg-blue-50 rounded border border-blue-200 text-xs">
<div className="font-semibold text-blue-900 mb-1">Alle gevonden matches:</div>
<ul className="space-y-1">
{item.allMatches.map((match, idx) => (
<li key={idx} className={match.excelApplicationName === item.excelApplicationName ? 'font-semibold text-blue-900' : 'text-blue-700'}>
{idx + 1}. "{match.excelApplicationName}" BIA: {match.biaValue} ({match.matchType}, {(match.confidence * 100).toFixed(0)}%)
{match.excelApplicationName === item.excelApplicationName && ' ← Huidige selectie'}
</li>
))}
</ul>
</div>
)}
</div>
)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">
{item.currentBIA ? (
<span className="font-medium">{item.currentBIA.name}</span>
) : (
<span className="text-gray-400">-</span>
)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">
{item.excelBIA ? (
<span className="font-medium">{item.excelBIA}</span>
) : (
<span className="text-gray-400">-</span>
)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={clsx(
'inline-flex px-2 py-1 text-xs font-semibold rounded-full',
getStatusBadgeClass(item.matchStatus)
)}
>
{getStatusLabel(item.matchStatus)}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-500">
{getMatchTypeLabel(item.matchType)}
{item.matchConfidence !== undefined && (
<span className="ml-1 text-xs">
({(item.matchConfidence * 100).toFixed(0)}%)
</span>
)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
{item.excelBIA && item.matchStatus !== 'match' && (
<button
onClick={() => handleSave(item)}
disabled={savingIds.has(item.id)}
className={clsx(
'text-blue-600 hover:text-blue-900',
savingIds.has(item.id) && 'opacity-50 cursor-not-allowed'
)}
>
{savingIds.has(item.id) ? (
<span className="flex items-center space-x-1">
<svg
className="animate-spin h-4 w-4"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<span>Opslaan...</span>
</span>
) : (
'Opslaan'
)}
</button>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* Results count */}
<div className="text-sm text-gray-500">
{filteredApplications.length} van {data.applications.length} applicaties getoond
</div>
</div>
);
}

View File

@@ -0,0 +1,347 @@
import { useEffect, useState, useMemo } from 'react';
import { ScatterChart, Scatter, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, Cell, ReferenceLine } from 'recharts';
import { getBusinessImportanceComparison } from '../services/api';
import type { BusinessImportanceComparisonItem } from '../types';
import { Link } from 'react-router-dom';
interface ScatterDataPoint {
x: number; // Business Importance (0-6)
y: number; // BIA Class (1-6)
name: string;
id: string;
key: string;
searchReference: string | null;
businessImportance: string | null;
biaClass: string | null;
discrepancyCategory: string;
discrepancyScore: number;
}
const CustomTooltip = ({ active, payload }: any) => {
if (active && payload && payload.length) {
const data = payload[0].payload as ScatterDataPoint;
return (
<div className="bg-white p-3 border border-gray-200 rounded-lg shadow-lg">
<p className="font-semibold text-gray-900">{data.name}</p>
<p className="text-sm text-gray-600">{data.key}</p>
<div className="mt-2 space-y-1 text-sm">
<p><span className="font-medium">Business Importance:</span> {data.businessImportance || 'Niet ingevuld'}</p>
<p><span className="font-medium">BIA Class:</span> {data.biaClass || 'Niet ingevuld'}</p>
<p><span className="font-medium">Discrepancy Score:</span> {data.discrepancyScore}</p>
<p><span className="font-medium">Category:</span> {
data.discrepancyCategory === 'high_bi_low_bia' ? 'High BI + Low BIA' :
data.discrepancyCategory === 'low_bi_high_bia' ? 'Low BI + High BIA' :
data.discrepancyCategory === 'aligned' ? 'Aligned' :
'Missing Data'
}</p>
</div>
</div>
);
}
return null;
};
function getCategoryColor(category: string): string {
switch (category) {
case 'high_bi_low_bia':
return '#DC2626'; // red-600 - IT thinks critical, business doesn't
case 'low_bi_high_bia':
return '#F59E0B'; // amber-500 - Business thinks critical, IT doesn't
case 'aligned':
return '#10B981'; // emerald-500 - Aligned
case 'missing_data':
return '#94A3B8'; // slate-400 - Missing data
default:
return '#6B7280'; // gray-500
}
}
export default function BusinessImportanceComparison() {
const [data, setData] = useState<BusinessImportanceComparisonItem[]>([]);
const [summary, setSummary] = useState({
total: 0,
withBothFields: 0,
highBiLowBia: 0,
lowBiHighBia: 0,
aligned: 0,
missingData: 0,
});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedCategory, setSelectedCategory] = useState<string>('all');
useEffect(() => {
async function fetchData() {
setLoading(true);
setError(null);
try {
const response = await getBusinessImportanceComparison();
setData(response.applications);
setSummary(response.summary);
} catch (err) {
console.error('Error fetching comparison data:', err);
setError(err instanceof Error ? err.message : 'Failed to load comparison data');
} finally {
setLoading(false);
}
}
fetchData();
}, []);
// Filter data based on selected category
const filteredData = useMemo(() => {
if (selectedCategory === 'all') return data;
return data.filter(item => item.discrepancyCategory === selectedCategory);
}, [data, selectedCategory]);
// Transform data for scatter plot (only include items with both fields)
const scatterData = useMemo(() => {
return filteredData
.filter(item => item.businessImportanceNormalized !== null && item.biaClassNormalized !== null)
.map(item => ({
x: item.businessImportanceNormalized!,
y: item.biaClassNormalized!,
name: item.name,
id: item.id,
key: item.key,
searchReference: item.searchReference,
businessImportance: item.businessImportance,
biaClass: item.biaClass,
discrepancyCategory: item.discrepancyCategory,
discrepancyScore: item.discrepancyScore,
}));
}, [filteredData]);
// Get discrepancy items for table
const discrepancyItems = useMemo(() => {
return filteredData
.filter(item => item.discrepancyCategory === 'high_bi_low_bia' || item.discrepancyCategory === 'low_bi_high_bia')
.sort((a, b) => b.discrepancyScore - a.discrepancyScore);
}, [filteredData]);
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) {
return (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-red-700">
{error}
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-gray-900">Business Importance vs Business Impact Analysis</h1>
<p className="mt-1 text-gray-500">
Vergelijking tussen IT-infrastructuur prioritering (Business Importance) en business owner beoordeling (Business Impact Analysis)
</p>
</div>
{/* Summary Statistics Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-6 gap-4">
<div className="card p-4">
<div className="text-sm text-gray-500 mb-1">Totaal</div>
<div className="text-2xl font-bold text-gray-900">{summary.total}</div>
</div>
<div className="card p-4">
<div className="text-sm text-gray-500 mb-1">Beide velden ingevuld</div>
<div className="text-2xl font-bold text-blue-600">{summary.withBothFields}</div>
</div>
<div className="card p-4">
<div className="text-sm text-gray-500 mb-1">High BI + Low BIA</div>
<div className="text-2xl font-bold text-red-600">{summary.highBiLowBia}</div>
</div>
<div className="card p-4">
<div className="text-sm text-gray-500 mb-1">Low BI + High BIA</div>
<div className="text-2xl font-bold text-amber-600">{summary.lowBiHighBia}</div>
</div>
<div className="card p-4">
<div className="text-sm text-gray-500 mb-1">Aligned</div>
<div className="text-2xl font-bold text-green-600">{summary.aligned}</div>
</div>
<div className="card p-4">
<div className="text-sm text-gray-500 mb-1">Missing Data</div>
<div className="text-2xl font-bold text-gray-600">{summary.missingData}</div>
</div>
</div>
{/* Scatter Plot */}
<div className="card p-6">
<h2 className="text-lg font-medium text-gray-900 mb-4">Scatter Plot</h2>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
Filter op categorie
</label>
<select
value={selectedCategory}
onChange={(e) => setSelectedCategory(e.target.value)}
className="block w-full md:w-64 rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
>
<option value="all">Alle applicaties</option>
<option value="high_bi_low_bia">High BI + Low BIA</option>
<option value="low_bi_high_bia">Low BI + High BIA</option>
<option value="aligned">Aligned</option>
<option value="missing_data">Missing Data</option>
</select>
</div>
{scatterData.length > 0 ? (
<ResponsiveContainer width="100%" height={500}>
<ScatterChart
margin={{ top: 20, right: 20, bottom: 20, left: 20 }}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
type="number"
dataKey="x"
name="Business Importance"
domain={[0, 6]}
ticks={[0, 1, 2, 3, 4, 5, 6]}
label={{ value: 'Business Importance (0=Critical Infrastructure, 6=Lowest)', position: 'insideBottom', offset: -5 }}
/>
<YAxis
type="number"
dataKey="y"
name="BIA Class"
domain={[1, 6]}
ticks={[1, 2, 3, 4, 5, 6]}
label={{ value: 'BIA Class (A=1, F=6)', angle: -90, position: 'insideLeft' }}
/>
<Tooltip content={<CustomTooltip />} />
<Legend />
{/* Reference lines for alignment zones */}
<ReferenceLine x={2} stroke="#9CA3AF" strokeDasharray="3 3" label={{ value: "High BI threshold", position: "top" }} />
<ReferenceLine x={5} stroke="#9CA3AF" strokeDasharray="3 3" label={{ value: "Low BI threshold", position: "top" }} />
<ReferenceLine y={2} stroke="#9CA3AF" strokeDasharray="3 3" label={{ value: "Low BIA threshold", position: "right" }} />
<ReferenceLine y={5} stroke="#9CA3AF" strokeDasharray="3 3" label={{ value: "High BIA threshold", position: "right" }} />
<Scatter name="Applications" data={scatterData} fill="#8884d8">
{scatterData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={getCategoryColor(entry.discrepancyCategory)} />
))}
</Scatter>
</ScatterChart>
</ResponsiveContainer>
) : (
<div className="text-center py-8 text-gray-500">
Geen data beschikbaar voor de scatter plot.
</div>
)}
<div className="mt-4 flex flex-wrap gap-4 text-sm">
<div className="flex items-center">
<div className="w-4 h-4 rounded-full bg-red-600 mr-2"></div>
<span>High BI + Low BIA (IT critical, business low)</span>
</div>
<div className="flex items-center">
<div className="w-4 h-4 rounded-full bg-amber-500 mr-2"></div>
<span>Low BI + High BIA (Business critical, IT low)</span>
</div>
<div className="flex items-center">
<div className="w-4 h-4 rounded-full bg-emerald-500 mr-2"></div>
<span>Aligned</span>
</div>
<div className="flex items-center">
<div className="w-4 h-4 rounded-full bg-slate-400 mr-2"></div>
<span>Missing Data</span>
</div>
</div>
</div>
{/* Discrepancy Table */}
<div className="card">
<div className="px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-medium text-gray-900">Discrepancies</h2>
<p className="text-sm text-gray-500 mt-1">
Applicaties met grote verschillen tussen Business Importance en Business Impact Analysis
</p>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Applicatie
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Business Importance
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
BIA Class
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Discrepancy Score
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Categorie
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{discrepancyItems.length === 0 ? (
<tr>
<td colSpan={5} className="px-6 py-4 text-center text-gray-500">
Geen discrepancies gevonden
</td>
</tr>
) : (
discrepancyItems.map((item) => (
<tr key={item.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<Link
to={`/application/${item.id}/edit`}
className="text-blue-600 hover:text-blue-800 font-medium"
>
{item.name}
</Link>
{item.searchReference && (
<div className="text-sm text-gray-500">{item.searchReference}</div>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{item.businessImportance || '-'}
{item.businessImportanceNormalized !== null && (
<span className="text-gray-500 ml-1">({item.businessImportanceNormalized})</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{item.biaClass || '-'}
{item.biaClassNormalized !== null && (
<span className="text-gray-500 ml-1">({item.biaClassNormalized})</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{item.discrepancyScore}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
item.discrepancyCategory === 'high_bi_low_bia'
? 'bg-red-100 text-red-800'
: item.discrepancyCategory === 'low_bi_high_bia'
? 'bg-amber-100 text-amber-800'
: 'bg-gray-100 text-gray-800'
}`}
>
{item.discrepancyCategory === 'high_bi_low_bia'
? 'High BI + Low BIA'
: item.discrepancyCategory === 'low_bi_high_bia'
? 'Low BI + High BIA'
: item.discrepancyCategory}
</span>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,360 @@
import { useEffect, useState } from 'react';
import { ScatterChart, Scatter, XAxis, YAxis, ZAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, Cell } from 'recharts';
import { searchApplications } from '../services/api';
import type { ApplicationListItem, ReferenceValue, ApplicationStatus } from '../types';
import { Link } from 'react-router-dom';
const ALL_STATUSES: ApplicationStatus[] = [
'In Production',
'Implementation',
'Proof of Concept',
'End of support',
'End of life',
'Deprecated',
'Shadow IT',
'Closed',
'Undefined',
];
interface BubbleDataPoint {
x: number; // Complexity
y: number; // Dynamics
z: number; // FTE (size)
bia: string; // BIA name for color
biaId: string; // BIA ID for color mapping
name: string; // Application name
id: string; // Application ID
key: string; // Application key
}
// Extract numeric value from Complexity/Dynamics ReferenceValue
// Try to extract from name (e.g., "1 - Stabiel" -> 1), or use order/factor
function getNumericValue(ref: ReferenceValue | null): number {
if (!ref) return 0;
// First try order property
if (ref.order !== undefined) return ref.order;
// Then try factor property
if (ref.factor !== undefined) return ref.factor;
// Try to extract number from name (e.g., "1 - Stabiel" or "DYN-2" -> 2)
const nameMatch = ref.name.match(/(\d+)/);
if (nameMatch) return parseInt(nameMatch[1], 10);
// Try to extract from key (e.g., "DYN-2" -> 2)
const keyMatch = ref.key.match(/-(\d+)$/);
if (keyMatch) return parseInt(keyMatch[1], 10);
return 0;
}
// Color mapping for BIA values
const BIA_COLORS: Record<string, string> = {
'Critical': '#DC2626', // red-600
'High': '#F59E0B', // amber-500
'Medium': '#10B981', // emerald-500
'Low': '#3B82F6', // blue-500
'Kritiek': '#DC2626',
'Hoog': '#F59E0B',
'Gemiddeld': '#10B981',
'Laag': '#3B82F6',
};
function getBIAColor(biaName: string | null): string {
if (!biaName) return '#94A3B8'; // slate-400 for no BIA
return BIA_COLORS[biaName] || '#6B7280'; // gray-500 default
}
const CustomTooltip = ({ active, payload }: any) => {
if (active && payload && payload.length) {
const data = payload[0].payload as BubbleDataPoint;
return (
<div className="bg-white p-3 border border-gray-200 rounded-lg shadow-lg">
<p className="font-semibold text-gray-900">{data.name}</p>
<p className="text-sm text-gray-600">{data.key}</p>
<div className="mt-2 space-y-1 text-sm">
<p><span className="font-medium">Complexity:</span> {data.x}</p>
<p><span className="font-medium">Dynamics:</span> {data.y}</p>
<p><span className="font-medium">FTE:</span> {data.z.toFixed(2)}</p>
<p><span className="font-medium">BIA:</span> {data.bia || 'Niet ingevuld'}</p>
</div>
</div>
);
}
return null;
};
export default function ComplexityDynamicsBubbleChart() {
const [data, setData] = useState<BubbleDataPoint[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [excludedStatuses, setExcludedStatuses] = useState<ApplicationStatus[]>(['Closed', 'Deprecated']);
useEffect(() => {
async function fetchData() {
setLoading(true);
setError(null);
try {
// Fetch all applications
const result = await searchApplications({}, 1, 10000);
// Transform applications to bubble chart data
const bubbleData: BubbleDataPoint[] = result.applications
.filter(app => {
// Filter out excluded statuses
if (!app.status) return false;
return !excludedStatuses.includes(app.status);
})
.map(app => {
const complexity = getNumericValue(app.complexityFactor);
const dynamics = getNumericValue(app.dynamicsFactor);
// Use overrideFTE if available, otherwise use calculated FTE
const fte = app.overrideFTE ?? app.requiredEffortApplicationManagement ?? 0;
return {
x: complexity,
y: dynamics,
z: Math.max(0.1, fte), // Minimum size for visibility
bia: app.businessImpactAnalyse?.name || null,
biaId: app.businessImpactAnalyse?.objectId || 'none',
name: app.name,
id: app.id,
key: app.key,
};
})
.filter(point => point.x > 0 && point.y > 0); // Only include apps with both complexity and dynamics
setData(bubbleData);
} catch (err) {
console.error('Error fetching bubble chart data:', err);
setError(err instanceof Error ? err.message : 'Failed to load bubble chart data');
} finally {
setLoading(false);
}
}
fetchData();
}, [excludedStatuses]);
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) {
return (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-red-700">
{error}
</div>
);
}
if (data.length === 0) {
return (
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4 text-gray-700">
Geen data beschikbaar voor de bubble chart.
</div>
);
}
// Get unique BIA values for legend
const uniqueBIAs = Array.from(new Set(data.map(d => d.bia).filter(Boolean))).sort();
// Find max values for axis scaling
const maxComplexity = Math.max(...data.map(d => d.x), 1);
const maxDynamics = Math.max(...data.map(d => d.y), 1);
return (
<div>
{/* Header */}
<div className="mb-8">
<h1 className="text-2xl font-bold text-gray-900">Complexity vs Dynamics Bubble Chart</h1>
<p className="mt-1 text-gray-500">
X-as: Complexity, Y-as: Dynamics, Grootte: FTE, Kleur: BIA
</p>
</div>
{/* Filters */}
<div className="mb-6">
<div className="block text-sm font-medium text-gray-700 mb-2">
Uitgesloten statussen
</div>
<div className="flex flex-wrap gap-2">
{ALL_STATUSES.map(status => {
const isExcluded = excludedStatuses.includes(status);
return (
<label
key={status}
className="flex items-center cursor-pointer"
onClick={(e) => {
// Stop event from bubbling to any parent handlers
e.stopPropagation();
}}
>
<input
type="checkbox"
checked={isExcluded}
onChange={(e) => {
// Stop propagation to prevent any parent handlers
e.stopPropagation();
const checked = e.target.checked;
setExcludedStatuses(prev => {
if (checked) {
// Add status if not already present
return prev.includes(status) ? prev : [...prev, status];
} else {
// Remove status
return prev.filter(s => s !== status);
}
});
}}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="ml-2 text-sm text-gray-700">{status}</span>
</label>
);
})}
</div>
</div>
{/* Bubble Chart */}
<div className="bg-white rounded-lg border border-gray-200 p-6 mb-6">
<ResponsiveContainer width="100%" height={600}>
<ScatterChart
margin={{ top: 20, right: 20, bottom: 60, left: 60 }}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
type="number"
dataKey="x"
name="Complexity"
label={{ value: 'Complexity', position: 'insideBottom', offset: -10 }}
domain={[0, maxComplexity + 0.5]}
ticks={Array.from({ length: Math.ceil(maxComplexity) + 1 }, (_, i) => i)}
/>
<YAxis
type="number"
dataKey="y"
name="Dynamics"
label={{ value: 'Dynamics', angle: -90, position: 'insideLeft' }}
domain={[0, maxDynamics + 0.5]}
ticks={Array.from({ length: Math.ceil(maxDynamics) + 1 }, (_, i) => i)}
/>
<ZAxis
type="number"
dataKey="z"
range={[50, 500]} // Bubble size range
name="FTE"
/>
<Tooltip content={<CustomTooltip />} />
<Scatter name="Applications" data={data} fill="#8884d8">
{data.map((entry, index) => (
<Cell key={`cell-${index}`} fill={getBIAColor(entry.bia)} />
))}
</Scatter>
</ScatterChart>
</ResponsiveContainer>
{/* Legend for BIA colors */}
<div className="mt-4 pt-4 border-t border-gray-200">
<h3 className="text-sm font-medium text-gray-700 mb-2">BIA Legenda:</h3>
<div className="flex flex-wrap gap-4">
{uniqueBIAs.map(bia => (
<div key={bia} className="flex items-center">
<div
className="w-4 h-4 rounded-full mr-2"
style={{ backgroundColor: getBIAColor(bia) }}
/>
<span className="text-sm text-gray-600">{bia}</span>
</div>
))}
<div className="flex items-center">
<div
className="w-4 h-4 rounded-full mr-2"
style={{ backgroundColor: getBIAColor(null) }}
/>
<span className="text-sm text-gray-600">Niet ingevuld</span>
</div>
</div>
</div>
</div>
{/* Data Table */}
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
<h2 className="text-lg font-semibold text-gray-900 p-4 border-b border-gray-200">
Applicaties ({data.length})
</h2>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Applicatie
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Complexity
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Dynamics
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
FTE
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
BIA
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{[...data]
.sort((a, b) => b.z - a.z) // Sort by FTE descending
.slice(0, 50) // Show top 50
.map((point) => (
<tr key={point.id}>
<td className="px-6 py-4 whitespace-nowrap">
<Link
to={`/application/${point.id}`}
className="text-sm font-medium text-blue-600 hover:text-blue-800"
>
{point.name}
</Link>
<div className="text-xs text-gray-500">{point.key}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{point.x}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{point.y}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{point.z.toFixed(2)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div
className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium"
style={{
backgroundColor: getBIAColor(point.bia) + '20',
color: getBIAColor(point.bia),
}}
>
{point.bia || 'Niet ingevuld'}
</div>
</td>
</tr>
))}
</tbody>
</table>
{data.length > 50 && (
<div className="px-6 py-3 bg-gray-50 text-sm text-gray-500 text-center">
Toont top 50 van {data.length} applicaties (gesorteerd op FTE)
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -1,7 +1,8 @@
import { useEffect, useState, useCallback, useRef } from 'react';
import { useEffect, useState, useCallback } from 'react';
import { Link } from 'react-router-dom';
import { getDashboardStats, getRecentClassifications, getReferenceData } from '../services/api';
import type { DashboardStats, ClassificationResult, ReferenceValue } from '../types';
import GovernanceModelBadge from './GovernanceModelBadge';
// Extended type to include stale indicator from API
interface DashboardStatsWithMeta extends DashboardStats {
@@ -13,35 +14,9 @@ export default function Dashboard() {
const [stats, setStats] = useState<DashboardStatsWithMeta | null>(null);
const [recentClassifications, setRecentClassifications] = useState<ClassificationResult[]>([]);
const [governanceModels, setGovernanceModels] = useState<ReferenceValue[]>([]);
const [hoveredGovModel, setHoveredGovModel] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = 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);
}
};
}, []);
const fetchData = useCallback(async (forceRefresh: boolean = false) => {
if (forceRefresh) {
@@ -144,7 +119,7 @@ export default function Dashboard() {
<div className="card p-6">
<div className="text-sm text-gray-500 mb-1">Totaal applicaties</div>
<div className="text-3xl font-bold text-gray-900">
{stats?.totalApplications || 0}
{stats?.totalAllApplications || stats?.totalApplications || 0}
</div>
</div>
@@ -248,12 +223,11 @@ export default function Dashboard() {
</div>
{/* Governance model distribution */}
<div className="card p-6" style={{ overflow: 'visible', position: 'relative', zIndex: hoveredGovModel ? 100 : 1 }}>
<h3 className="text-lg font-medium text-gray-900 mb-4 flex items-center gap-2">
<div className="card p-6" style={{ overflow: 'visible', position: 'relative' }}>
<h3 className="text-lg font-medium text-gray-900 mb-4">
Verdeling per regiemodel
<span className="text-gray-400 text-xs font-normal" title="Hover voor details"></span>
</h3>
<div className="flex flex-wrap gap-2" style={{ overflow: 'visible' }}>
<div className="space-y-3" style={{ overflow: 'visible' }}>
{stats?.byGovernanceModel &&
[
...governanceModels
@@ -261,94 +235,33 @@ export default function Dashboard() {
.sort((a, b) => a.localeCompare(b, 'nl', { sensitivity: 'base' })),
'Niet ingesteld'
]
.filter(govModel => stats.byGovernanceModel[govModel] !== undefined || govModel === 'Niet ingesteld')
.map((govModel) => {
const count = stats.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' };
})();
const shortLabel = govModel === 'Niet ingesteld'
? '?'
: (govModel.match(/Regiemodel\s+(.+)/i)?.[1] || govModel.charAt(0));
const govModelData = governanceModels.find(g => g.name === govModel);
const isHovered = hoveredGovModel === govModel;
return (
<div
key={govModel}
className="rounded-xl py-2 shadow-sm hover:shadow-lg transition-all duration-200 w-[48px] text-center cursor-pointer"
style={{
backgroundColor: colors.bg,
color: colors.text,
position: 'relative'
}}
onMouseEnter={() => handleGovModelMouseEnter(govModel)}
onMouseLeave={handleGovModelMouseLeave}
>
<div className="text-[10px] font-bold uppercase tracking-wider" style={{ opacity: 0.9 }}>
{shortLabel}
<div key={govModel} className="flex items-center justify-between">
<div className="flex items-center gap-2">
<GovernanceModelBadge
governanceModelName={govModel}
governanceModelData={govModelData}
size="sm"
showPopup={true}
/>
<span className="text-sm text-gray-600">{govModel}</span>
</div>
<div className="text-xl font-bold leading-tight">
{count}
</div>
{/* Hover popup */}
{isHovered && govModel !== 'Niet ingesteld' && (
<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'
}}
>
{/* Arrow pointer */}
<div
className="absolute -top-2 left-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))' }}
<div className="flex items-center space-x-2">
<div className="w-32 bg-gray-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full"
style={{
width: `${(count / (stats?.totalAllApplications || 1)) * 100}%`,
}}
/>
{/* 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>
)}
<span className="text-sm font-medium text-gray-900 w-8 text-right">
{count}
</span>
</div>
</div>
);
})}

View File

@@ -0,0 +1,821 @@
import { useState, useEffect } from 'react';
import {
getDataCompletenessConfig,
updateDataCompletenessConfig,
getSchema,
type DataCompletenessConfig,
type CompletenessFieldConfig,
type CompletenessCategoryConfig,
type SchemaResponse,
} from '../services/api';
// Mapping from schema fieldName to ApplicationDetails field path
// Some fields have different names in ApplicationDetails vs schema
const FIELD_NAME_TO_PATH_MAP: Record<string, string> = {
// Direct mappings
'organisation': 'organisation',
'status': 'status',
'businessImpactAnalyse': 'businessImpactAnalyse',
'supplierProduct': 'supplierProduct',
'businessOwner': 'businessOwner',
'systemOwner': 'systemOwner',
'functionalApplicationManagement': 'functionalApplicationManagement',
'technicalApplicationManagement': 'technicalApplicationManagement',
'technicalApplicationManagementPrimary': 'technicalApplicationManagementPrimary',
'technicalApplicationManagementSecondary': 'technicalApplicationManagementSecondary',
'description': 'description',
'searchReference': 'searchReference',
'businessImportance': 'businessImportance',
'applicationManagementHosting': 'applicationManagementHosting',
'applicationManagementTAM': 'applicationManagementTAM',
'platform': 'platform',
// Different names (schema fieldName -> ApplicationDetails property)
'applicationFunction': 'applicationFunctions', // Note: plural in ApplicationDetails
'applicationComponentHostingType': 'hostingType',
'ictGovernanceModel': 'governanceModel',
'applicationManagementApplicationType': 'applicationType',
'applicationManagementDynamicsFactor': 'dynamicsFactor',
'applicationManagementComplexityFactor': 'complexityFactor',
'applicationManagementNumberOfUsers': 'numberOfUsers',
'applicationManagementSubteam': 'applicationSubteam',
'applicationManagementOverrideFTE': 'overrideFTE',
'technischeArchitectuurTA': 'technischeArchitectuur',
// Note: applicationTeam is not directly on ApplicationComponent, it's looked up via subteam
};
function generateId(): string {
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
export default function DataCompletenessConfig() {
const [config, setConfig] = useState<DataCompletenessConfig | null>(null);
const [schema, setSchema] = useState<SchemaResponse | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [editingCategory, setEditingCategory] = useState<string | null>(null);
const [editingCategoryName, setEditingCategoryName] = useState('');
const [editingCategoryDescription, setEditingCategoryDescription] = useState('');
const [addingFieldToCategory, setAddingFieldToCategory] = useState<string | null>(null);
const [newFieldName, setNewFieldName] = useState('');
const [newFieldPath, setNewFieldPath] = useState('');
const [newCategoryName, setNewCategoryName] = useState('');
const [newCategoryDescription, setNewCategoryDescription] = useState('');
const [availableFields, setAvailableFields] = useState<Array<{ name: string; fieldPath: string }>>([]);
const [draggedField, setDraggedField] = useState<{ categoryId: string; fieldIndex: number; field: CompletenessFieldConfig } | null>(null);
const [draggedCategory, setDraggedCategory] = useState<{ categoryId: string; categoryIndex: number } | null>(null);
useEffect(() => {
loadConfig();
loadSchema();
}, []);
const loadSchema = async () => {
try {
const schemaData = await getSchema();
setSchema(schemaData);
// Extract fields from ApplicationComponent object type
const applicationComponentType = schemaData.objectTypes['ApplicationComponent'];
if (applicationComponentType) {
const fields = applicationComponentType.attributes
.filter(attr => {
// Include editable attributes or non-system attributes
return attr.isEditable || !attr.isSystem;
})
.map(attr => {
// Map schema fieldName to ApplicationDetails field path
const fieldPath = FIELD_NAME_TO_PATH_MAP[attr.fieldName] || attr.fieldName;
return {
name: attr.name,
fieldPath: fieldPath,
type: attr.type,
description: attr.description,
};
})
// Remove duplicates and sort by name
.filter((field, index, self) =>
index === self.findIndex(f => f.fieldPath === field.fieldPath)
)
.sort((a, b) => a.name.localeCompare(b.name));
setAvailableFields(fields.map(f => ({ name: f.name, fieldPath: f.fieldPath })));
}
} catch (err) {
console.error('Failed to load schema:', err);
// Continue without schema - user can still enter custom fields
}
};
const loadConfig = async () => {
try {
setLoading(true);
setError(null);
const data = await getDataCompletenessConfig();
setConfig(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load configuration');
} finally {
setLoading(false);
}
};
const handleSave = async () => {
if (!config) return;
try {
setSaving(true);
setError(null);
setSuccess(null);
await updateDataCompletenessConfig(config);
setSuccess('Configuration saved successfully!');
setTimeout(() => setSuccess(null), 3000);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save configuration');
} finally {
setSaving(false);
}
};
const addCategory = () => {
if (!config || !newCategoryName.trim()) return;
const newCategory: CompletenessCategoryConfig = {
id: generateId(),
name: newCategoryName.trim(),
description: newCategoryDescription.trim(),
fields: [],
};
setConfig({
...config,
categories: [...config.categories, newCategory],
});
setNewCategoryName('');
setNewCategoryDescription('');
};
const deleteCategory = (categoryId: string) => {
if (!config) return;
if (!confirm('Are you sure you want to delete this category? This cannot be undone.')) return;
setConfig({
...config,
categories: config.categories.filter(c => c.id !== categoryId),
});
};
const startEditingCategory = (categoryId: string) => {
if (!config) return;
const category = config.categories.find(c => c.id === categoryId);
if (!category) return;
setEditingCategory(categoryId);
setEditingCategoryName(category.name);
setEditingCategoryDescription(category.description || '');
};
const cancelEditingCategory = () => {
setEditingCategory(null);
setEditingCategoryName('');
setEditingCategoryDescription('');
};
const saveEditingCategory = (categoryId: string) => {
if (!config) return;
setConfig({
...config,
categories: config.categories.map(cat =>
cat.id === categoryId
? { ...cat, name: editingCategoryName.trim(), description: editingCategoryDescription.trim() }
: cat
),
});
cancelEditingCategory();
};
const addFieldToCategory = (categoryId: string) => {
if (!config || !newFieldName.trim() || !newFieldPath.trim()) return;
const category = config.categories.find(c => c.id === categoryId);
if (!category) return;
// Check if field already exists in this category
if (category.fields.some(f => f.fieldPath === newFieldPath.trim())) {
setError(`Field "${newFieldName.trim()}" is already in this category`);
setTimeout(() => setError(null), 3000);
return;
}
const newField: CompletenessFieldConfig = {
id: generateId(),
name: newFieldName.trim(),
fieldPath: newFieldPath.trim(),
enabled: true,
};
setConfig({
...config,
categories: config.categories.map(cat =>
cat.id === categoryId
? { ...cat, fields: [...cat.fields, newField] }
: cat
),
});
setNewFieldName('');
setNewFieldPath('');
setAddingFieldToCategory(null);
};
const removeFieldFromCategory = (categoryId: string, fieldId: string) => {
if (!config) return;
setConfig({
...config,
categories: config.categories.map(cat =>
cat.id === categoryId
? { ...cat, fields: cat.fields.filter(f => f.id !== fieldId) }
: cat
),
});
};
const toggleField = (categoryId: string, fieldId: string) => {
if (!config) return;
setConfig({
...config,
categories: config.categories.map(cat =>
cat.id === categoryId
? {
...cat,
fields: cat.fields.map(field =>
field.id === fieldId ? { ...field, enabled: !field.enabled } : field
),
}
: cat
),
});
};
const handleFieldDragStart = (categoryId: string, fieldIndex: number) => {
if (!config) return;
const category = config.categories.find(c => c.id === categoryId);
if (!category) return;
const field = category.fields[fieldIndex];
if (!field) return;
setDraggedField({ categoryId, fieldIndex, field });
};
const handleFieldDragOver = (e: React.DragEvent, categoryId: string, fieldIndex: number) => {
e.preventDefault();
if (!draggedField || !config) return;
// Same category - reorder within category
if (draggedField.categoryId === categoryId) {
if (draggedField.fieldIndex === fieldIndex) return;
const category = config.categories.find(c => c.id === categoryId);
if (!category) return;
const newFields = [...category.fields];
const draggedItem = newFields[draggedField.fieldIndex];
newFields.splice(draggedField.fieldIndex, 1);
newFields.splice(fieldIndex, 0, draggedItem);
setConfig({
...config,
categories: config.categories.map(cat =>
cat.id === categoryId ? { ...cat, fields: newFields } : cat
),
});
setDraggedField({ ...draggedField, fieldIndex });
} else {
// Different category - move to new category
const targetCategory = config.categories.find(c => c.id === categoryId);
if (!targetCategory) return;
// Check if field already exists in target category
if (targetCategory.fields.some(f => f.id === draggedField.field.id)) return;
// Remove from source category
const sourceCategory = config.categories.find(c => c.id === draggedField.categoryId);
if (!sourceCategory) return;
const sourceFields = sourceCategory.fields.filter(f => f.id !== draggedField.field.id);
// Add to target category at the specified index
const targetFields = [...targetCategory.fields];
targetFields.splice(fieldIndex, 0, draggedField.field);
setConfig({
...config,
categories: config.categories.map(cat => {
if (cat.id === draggedField.categoryId) {
return { ...cat, fields: sourceFields };
}
if (cat.id === categoryId) {
return { ...cat, fields: targetFields };
}
return cat;
}),
});
setDraggedField({ categoryId, fieldIndex, field: draggedField.field });
}
};
const handleCategoryFieldDragOver = (e: React.DragEvent, categoryId: string) => {
e.preventDefault();
if (!draggedField || !config) return;
// Only allow dropping if dragging from a different category
if (draggedField.categoryId === categoryId) return;
const targetCategory = config.categories.find(c => c.id === categoryId);
if (!targetCategory) return;
// Check if field already exists in target category
if (targetCategory.fields.some(f => f.id === draggedField.field.id)) return;
};
const handleFieldDrop = (e: React.DragEvent) => {
e.preventDefault();
setDraggedField(null);
};
const handleCategoryFieldDrop = (e: React.DragEvent, categoryId: string) => {
e.preventDefault();
if (!draggedField || !config) return;
// Only allow dropping if dragging from a different category
if (draggedField.categoryId === categoryId) {
setDraggedField(null);
return;
}
const targetCategory = config.categories.find(c => c.id === categoryId);
if (!targetCategory) {
setDraggedField(null);
return;
}
// Check if field already exists in target category
if (targetCategory.fields.some(f => f.id === draggedField.field.id)) {
setDraggedField(null);
return;
}
// Remove from source category
const sourceCategory = config.categories.find(c => c.id === draggedField.categoryId);
if (!sourceCategory) {
setDraggedField(null);
return;
}
const sourceFields = sourceCategory.fields.filter(f => f.id !== draggedField.field.id);
// Add to target category at the end
const targetFields = [...targetCategory.fields, draggedField.field];
setConfig({
...config,
categories: config.categories.map(cat => {
if (cat.id === draggedField.categoryId) {
return { ...cat, fields: sourceFields };
}
if (cat.id === categoryId) {
return { ...cat, fields: targetFields };
}
return cat;
}),
});
setDraggedField(null);
};
const handleCategoryDragStart = (categoryId: string, categoryIndex: number) => {
setDraggedCategory({ categoryId, categoryIndex });
};
const handleCategoryBlockDragOver = (e: React.DragEvent, categoryId: string, categoryIndex: number) => {
e.preventDefault();
e.stopPropagation();
if (!draggedCategory || !config) return;
if (draggedCategory.categoryId === categoryId) return;
if (draggedCategory.categoryIndex === categoryIndex) return;
const newCategories = [...config.categories];
const draggedItem = newCategories[draggedCategory.categoryIndex];
newCategories.splice(draggedCategory.categoryIndex, 1);
newCategories.splice(categoryIndex, 0, draggedItem);
setConfig({
...config,
categories: newCategories,
});
setDraggedCategory({ categoryId, categoryIndex });
};
const handleCategoryDragEnd = () => {
setDraggedCategory(null);
};
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 && !config) {
return (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-red-700">
{error}
</div>
);
}
if (!config) {
return (
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4 text-gray-700">
No configuration available
</div>
);
}
return (
<div>
{/* Header */}
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900">Data Completeness Configuration</h1>
<p className="mt-1 text-sm text-gray-500">
Create and configure categories and fields for the Data Completeness Score calculation.
Fields are dynamically loaded from the "Application Component" object type in the datamodel.
</p>
</div>
{/* Success/Error Messages */}
{success && (
<div className="mb-4 bg-green-50 border border-green-200 rounded-lg p-4 text-green-700">
{success}
</div>
)}
{error && (
<div className="mb-4 bg-red-50 border border-red-200 rounded-lg p-4 text-red-700">
{error}
</div>
)}
{/* Categories */}
{config.categories.map((category, categoryIndex) => {
const enabledCount = category.fields.filter(f => f.enabled).length;
const isEditing = editingCategory === category.id;
const isAddingField = addingFieldToCategory === category.id;
const isDraggedCategory = draggedCategory?.categoryId === category.id;
return (
<div
key={category.id}
draggable
onDragStart={(e) => {
// Only allow dragging when not editing and not adding a field
if (!isEditing && !isAddingField) {
handleCategoryDragStart(category.id, categoryIndex);
} else {
e.preventDefault();
}
}}
onDragOver={(e) => {
// Only allow reordering when not editing and not adding a field
if (!isEditing && !isAddingField) {
handleCategoryBlockDragOver(e, category.id, categoryIndex);
}
}}
onDragEnd={handleCategoryDragEnd}
className={`bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-6 ${
isDraggedCategory ? 'opacity-50' : ''
} ${!isEditing && !isAddingField ? 'cursor-move' : ''}`}
>
{/* Category Header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2 flex-1">
{!isEditing && !isAddingField && (
<div className="flex-shrink-0 text-gray-400 cursor-move" title="Drag to reorder category">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 8h16M4 16h16" />
</svg>
</div>
)}
<div className="flex-1">
{isEditing ? (
<div className="space-y-2">
<input
type="text"
value={editingCategoryName}
onChange={(e) => setEditingCategoryName(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-blue-500"
placeholder="Category Name"
/>
<input
type="text"
value={editingCategoryDescription}
onChange={(e) => setEditingCategoryDescription(e.target.value)}
placeholder="Description"
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
<div className="flex gap-2">
<button
onClick={() => saveEditingCategory(category.id)}
disabled={!editingCategoryName.trim()}
className="px-3 py-1.5 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Save
</button>
<button
onClick={cancelEditingCategory}
className="px-3 py-1.5 text-sm font-medium text-gray-600 hover:text-gray-800 border border-gray-300 rounded-md hover:bg-gray-50"
>
Cancel
</button>
</div>
</div>
) : (
<div>
<h2 className="text-lg font-semibold text-gray-900">{category.name}</h2>
<p className="text-sm text-gray-500 mt-1">{category.description || 'No description'}</p>
</div>
)}
</div>
</div>
<div className="flex items-center gap-4 ml-4">
<span className="text-sm text-gray-600">
{enabledCount} / {category.fields.length} enabled
</span>
{!isEditing && (
<>
<button
onClick={() => startEditingCategory(category.id)}
className="px-3 py-1.5 text-sm font-medium text-gray-600 hover:text-gray-800 border border-gray-300 rounded-md hover:bg-gray-50"
>
Edit
</button>
<button
onClick={() => deleteCategory(category.id)}
className="px-3 py-1.5 text-sm font-medium text-red-600 hover:text-red-800 border border-red-300 rounded-md hover:bg-red-50"
>
Delete
</button>
</>
)}
</div>
</div>
{/* Fields List */}
<div
className="space-y-1 mb-4"
onDragOver={(e) => handleCategoryFieldDragOver(e, category.id)}
onDrop={(e) => handleCategoryFieldDrop(e, category.id)}
>
{category.fields.length === 0 ? (
<div className={`text-center text-gray-500 py-3 border border-dashed rounded-lg text-sm ${
draggedField && draggedField.categoryId !== category.id
? 'border-blue-400 bg-blue-50'
: 'border-gray-300'
}`}>
No fields in this category. {draggedField && draggedField.categoryId !== category.id ? 'Drop a field here' : 'Click "Add Field" to add one.'}
</div>
) : (
category.fields.map((field, index) => (
<div
key={field.id}
draggable
onDragStart={() => handleFieldDragStart(category.id, index)}
onDragOver={(e) => handleFieldDragOver(e, category.id, index)}
onDrop={handleFieldDrop}
className={`flex items-center justify-between py-1.5 px-3 border border-gray-200 rounded hover:bg-gray-50 cursor-move ${
draggedField?.categoryId === category.id && draggedField?.fieldIndex === index
? 'opacity-50'
: ''
} ${
draggedField && draggedField.categoryId !== category.id && draggedField.fieldIndex === index
? 'border-blue-400 bg-blue-50'
: ''
}`}
>
<div className="flex items-center gap-2 flex-1 min-w-0">
{/* Drag handle icon */}
<div className="flex-shrink-0 text-gray-400 cursor-move">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 8h16M4 16h16" />
</svg>
</div>
<input
type="checkbox"
checked={field.enabled}
onChange={() => toggleField(category.id, field.id)}
onClick={(e) => e.stopPropagation()}
className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500 flex-shrink-0"
/>
<label
className="text-sm text-gray-900 cursor-pointer flex items-center gap-2 min-w-0 flex-1"
onClick={() => toggleField(category.id, field.id)}
>
<span className="font-medium">{field.name}</span>
<span className="text-xs text-gray-500">
<code className="bg-gray-100 px-1.5 py-0.5 rounded">{field.fieldPath}</code>
</span>
</label>
</div>
<div className="flex items-center gap-2 ml-3 flex-shrink-0">
<div className={`px-2 py-0.5 text-xs font-medium rounded-full ${
field.enabled
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-600'
}`}>
{field.enabled ? 'Enabled' : 'Disabled'}
</div>
<button
onClick={(e) => {
e.stopPropagation();
removeFieldFromCategory(category.id, field.id);
}}
className="text-red-600 hover:text-red-800 text-xs font-medium"
>
Remove
</button>
</div>
</div>
))
)}
</div>
{/* Add Field Form - Below the list */}
{isAddingField && (
<div className="mb-4 p-4 bg-gray-50 rounded-lg border border-gray-200">
<h3 className="text-sm font-medium text-gray-900 mb-3">Add Field to {category.name}</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Select Field from Application Component *
</label>
<select
value={newFieldPath}
onChange={(e) => {
const selectedField = availableFields.find(f => f.fieldPath === e.target.value);
if (selectedField) {
setNewFieldPath(selectedField.fieldPath);
setNewFieldName(selectedField.name);
} else {
setNewFieldPath(e.target.value);
}
}}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500 bg-white"
>
<option value="">-- Select a field --</option>
{availableFields.map((field) => (
<option key={field.fieldPath} value={field.fieldPath}>
{field.name} ({field.fieldPath})
</option>
))}
</select>
<p className="mt-1 text-xs text-gray-500">
Only fields from the "Application Component" object type are available
</p>
</div>
{/* Display selected field info (read-only) */}
{newFieldPath && newFieldName && (
<div className="p-3 bg-blue-50 border border-blue-200 rounded-md">
<div className="text-sm">
<span className="font-medium text-gray-700">Selected Field:</span>{' '}
<span className="text-gray-900">{newFieldName}</span>
</div>
<div className="text-xs text-gray-600 mt-1">
Field Path: <code className="bg-blue-100 px-1 py-0.5 rounded">{newFieldPath}</code>
</div>
</div>
)}
</div>
<div className="mt-4 flex justify-end gap-2">
<button
onClick={() => {
setAddingFieldToCategory(null);
setNewFieldName('');
setNewFieldPath('');
}}
className="px-4 py-2 text-sm font-medium text-gray-600 hover:text-gray-800 border border-gray-300 rounded-md hover:bg-gray-50"
>
Cancel
</button>
<button
onClick={() => addFieldToCategory(category.id)}
disabled={!newFieldName.trim() || !newFieldPath.trim()}
className="px-4 py-2 text-sm font-medium text-white bg-green-600 border border-transparent rounded-md hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Add Field
</button>
</div>
</div>
)}
{/* Add Field Button - Below the list */}
{!isEditing && !isAddingField && (
<div className="mb-4">
<button
onClick={() => setAddingFieldToCategory(category.id)}
className="px-3 py-1.5 text-sm font-medium text-green-600 hover:text-green-800 border border-green-300 rounded-md hover:bg-green-50"
>
Add Field
</button>
</div>
)}
</div>
);
})}
{/* Add New Category */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Add New Category</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Category Name *
</label>
<input
type="text"
value={newCategoryName}
onChange={(e) => setNewCategoryName(e.target.value)}
placeholder="e.g., Security, Compliance"
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Description
</label>
<input
type="text"
value={newCategoryDescription}
onChange={(e) => setNewCategoryDescription(e.target.value)}
placeholder="Brief description"
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div className="flex items-end">
<button
onClick={addCategory}
disabled={!newCategoryName.trim()}
className="w-full px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Add Category
</button>
</div>
</div>
</div>
{/* Save Button */}
<div className="flex justify-end gap-4">
<button
onClick={loadConfig}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
>
Reset
</button>
<button
onClick={handleSave}
disabled={saving}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{saving ? 'Saving...' : 'Save Configuration'}
</button>
</div>
{/* Info Box */}
<div className="mt-6 bg-blue-50 border border-blue-200 rounded-lg p-4">
<h3 className="text-sm font-medium text-blue-900 mb-2">About Data Completeness Configuration</h3>
<ul className="text-sm text-blue-800 space-y-1 list-disc list-inside">
<li>Fields are dynamically loaded from the "Application Component" object type in your Jira Assets schema</li>
<li>Create custom categories to organize fields for completeness checking</li>
<li>Add fields to categories by selecting from available schema fields or entering custom field paths</li>
<li>Only enabled fields are included in the completeness score calculation</li>
<li>Changes take effect immediately after saving</li>
<li>The field path determines which property in the ApplicationDetails object is checked</li>
</ul>
</div>
</div>
);
}

View File

@@ -0,0 +1,495 @@
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { getDataCompletenessConfig, type DataCompletenessConfig } from '../services/api';
interface FieldCompleteness {
field: string;
category: 'general' | 'applicationManagement';
filled: number;
total: number;
percentage: number;
}
interface ApplicationCompleteness {
id: string;
key: string;
name: string;
team: string | null;
subteam: string | null;
generalScore: number;
applicationManagementScore: number;
overallScore: number;
filledFields: number;
totalFields: number;
}
interface TeamCompleteness {
team: string;
generalScore: number;
applicationManagementScore: number;
overallScore: number;
applicationCount: number;
filledFields: number;
totalFields: number;
}
interface CompletenessData {
overall: {
generalScore: number;
applicationManagementScore: number;
overallScore: number;
totalApplications: number;
filledFields: number;
totalFields: number;
categoryScores?: Record<string, number>; // Dynamic category scores
};
byField: FieldCompleteness[];
byApplication: ApplicationCompleteness[];
byTeam: TeamCompleteness[];
}
const API_BASE = '/api';
function getScoreColor(score: number): string {
if (score >= 90) return 'text-green-600 bg-green-50';
if (score >= 75) return 'text-yellow-600 bg-yellow-50';
if (score >= 50) return 'text-orange-600 bg-orange-50';
return 'text-red-600 bg-red-50';
}
function getScoreBadgeColor(score: number): string {
if (score >= 90) return 'bg-green-500';
if (score >= 75) return 'bg-yellow-500';
if (score >= 50) return 'bg-orange-500';
return 'bg-red-500';
}
export default function DataCompletenessScore() {
const [data, setData] = useState<CompletenessData | null>(null);
const [config, setConfig] = useState<DataCompletenessConfig | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedTeam, setSelectedTeam] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [sortBy, setSortBy] = useState<'score' | 'name'>('score');
useEffect(() => {
async function fetchData() {
setLoading(true);
setError(null);
try {
// Fetch config and data in parallel
const [configResult, dataResponse] = await Promise.all([
getDataCompletenessConfig(),
fetch(`${API_BASE}/dashboard/data-completeness`)
]);
setConfig(configResult);
if (!dataResponse.ok) {
throw new Error('Failed to fetch data completeness data');
}
const result = await dataResponse.json();
setData(result);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load data');
} finally {
setLoading(false);
}
}
fetchData();
}, []);
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) {
return (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-red-700">
{error}
</div>
);
}
if (!data) {
return (
<div className="bg-gray-50 border border-gray-200 rounded-lg p-8 text-center">
<p className="text-gray-500">Geen data beschikbaar</p>
</div>
);
}
// Filter applications
const filteredApplications = data.byApplication
.filter(app => {
const matchesSearch = !searchQuery ||
app.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
app.key.toLowerCase().includes(searchQuery.toLowerCase());
const matchesTeam = !selectedTeam || app.team === selectedTeam;
return matchesSearch && matchesTeam;
})
.sort((a, b) => {
if (sortBy === 'score') {
return b.overallScore - a.overallScore;
}
return a.name.localeCompare(b.name, 'nl', { sensitivity: 'base' });
});
return (
<div>
{/* Header */}
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900">Data Completeness Score</h1>
<p className="mt-1 text-sm text-gray-500">
Percentage van verplichte velden ingevuld per applicatie, per team en overall.
</p>
</div>
{/* Overall Score Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div className="text-sm text-gray-500 mb-2">Overall Score</div>
<div className="flex items-baseline gap-2">
<div className={`text-4xl font-bold ${getScoreColor(data.overall.overallScore).split(' ')[0]}`}>
{data.overall.overallScore.toFixed(1)}%
</div>
<div className="text-sm text-gray-500">
({data.overall.filledFields} / {data.overall.totalFields} velden)
</div>
</div>
<div className="mt-4">
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className={`h-2 rounded-full ${getScoreBadgeColor(data.overall.overallScore)}`}
style={{ width: `${data.overall.overallScore}%` }}
></div>
</div>
</div>
</div>
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div className="text-sm text-gray-500 mb-2">General Category</div>
<div className="flex items-baseline gap-2">
<div className={`text-4xl font-bold ${getScoreColor(data.overall.generalScore).split(' ')[0]}`}>
{data.overall.generalScore.toFixed(1)}%
</div>
<div className="text-sm text-gray-500">
({config?.categories.find(c => c.id === 'general')?.fields.filter(f => f.enabled).length || config?.categories[0]?.fields.filter(f => f.enabled).length || 0} velden)
</div>
</div>
<div className="mt-4">
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className={`h-2 rounded-full ${getScoreBadgeColor(data.overall.generalScore)}`}
style={{ width: `${data.overall.generalScore}%` }}
></div>
</div>
</div>
</div>
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div className="text-sm text-gray-500 mb-2">Application Management</div>
<div className="flex items-baseline gap-2">
<div className={`text-4xl font-bold ${getScoreColor(data.overall.applicationManagementScore).split(' ')[0]}`}>
{data.overall.applicationManagementScore.toFixed(1)}%
</div>
<div className="text-sm text-gray-500">
({config?.categories.find(c => c.id === 'applicationManagement')?.fields.filter(f => f.enabled).length || config?.categories[1]?.fields.filter(f => f.enabled).length || 0} velden)
</div>
</div>
<div className="mt-4">
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className={`h-2 rounded-full ${getScoreBadgeColor(data.overall.applicationManagementScore)}`}
style={{ width: `${data.overall.applicationManagementScore}%` }}
></div>
</div>
</div>
</div>
</div>
{/* Field Completeness Table */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Veld Completeness</h2>
<div className="space-y-4">
{/* Dynamic Categories */}
{config?.categories.map((category, index) => {
const categoryFields = data.byField.filter(f => f.category === category.id);
if (categoryFields.length === 0) return null;
const categoryScore = data.overall.categoryScores?.[category.id] ?? 0;
return (
<div key={category.id} className={index > 0 ? 'mt-6' : ''}>
<div className="bg-gray-50 rounded-md px-3 py-2 mb-2 flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold text-gray-900 uppercase tracking-wide">{category.name}</h3>
{category.description && (
<p className="text-xs text-gray-500 mt-0.5">{category.description}</p>
)}
</div>
<div className="flex items-center gap-2">
<span className={`text-lg font-bold ${getScoreColor(categoryScore).split(' ')[0]}`}>
{categoryScore.toFixed(1)}%
</span>
</div>
</div>
<div className="space-y-2 px-2">
{categoryFields.map(field => (
<div key={field.field} className="flex items-center gap-4">
<div className="w-80 text-sm text-gray-700 flex-shrink-0">
{field.field}
</div>
<div className="flex-1">
<div className="w-full bg-gray-200 rounded-full h-3">
<div
className={`h-3 rounded-full ${getScoreBadgeColor(field.percentage)}`}
style={{ width: `${field.percentage}%` }}
></div>
</div>
</div>
<div className="w-24 text-right text-sm font-medium text-gray-900">
{field.percentage.toFixed(1)}%
</div>
<div className="w-32 text-right text-xs text-gray-500">
{field.filled} / {field.total}
</div>
</div>
))}
</div>
</div>
);
})}
</div>
</div>
{/* Team Scores */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Scores per Team</h2>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Team
</th>
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">
Overall Score
</th>
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">
General
</th>
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">
App. Management
</th>
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">
Applicaties
</th>
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">
Velden
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{data.byTeam
.sort((a, b) => b.overallScore - a.overallScore)
.map((team) => (
<tr
key={team.team}
className={`hover:bg-gray-50 cursor-pointer ${selectedTeam === team.team ? 'bg-blue-50' : ''}`}
onClick={() => setSelectedTeam(selectedTeam === team.team ? null : team.team)}
>
<td className="px-4 py-3 text-sm font-medium text-gray-900">
{team.team || 'Niet toegekend'}
</td>
<td className="px-4 py-3 text-center">
<div className="flex items-center justify-center gap-2">
<div className={`text-sm font-bold ${getScoreColor(team.overallScore).split(' ')[0]}`}>
{team.overallScore.toFixed(1)}%
</div>
<div className="w-16 bg-gray-200 rounded-full h-2">
<div
className={`h-2 rounded-full ${getScoreBadgeColor(team.overallScore)}`}
style={{ width: `${team.overallScore}%` }}
></div>
</div>
</div>
</td>
<td className="px-4 py-3 text-center">
<span className={`text-sm font-medium ${getScoreColor(team.generalScore).split(' ')[0]}`}>
{team.generalScore.toFixed(1)}%
</span>
</td>
<td className="px-4 py-3 text-center">
<span className={`text-sm font-medium ${getScoreColor(team.applicationManagementScore).split(' ')[0]}`}>
{team.applicationManagementScore.toFixed(1)}%
</span>
</td>
<td className="px-4 py-3 text-center text-sm text-gray-500">
{team.applicationCount}
</td>
<td className="px-4 py-3 text-center text-sm text-gray-500">
{team.filledFields} / {team.totalFields}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Filters */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4 mb-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Search */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Zoeken
</label>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Naam of key..."
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
{/* Team Filter */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Team
</label>
<select
value={selectedTeam || ''}
onChange={(e) => setSelectedTeam(e.target.value || null)}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
>
<option value="">Alle teams</option>
{data.byTeam.map(team => (
<option key={team.team} value={team.team}>
{team.team || 'Niet toegekend'} ({team.applicationCount})
</option>
))}
</select>
</div>
{/* Sort By */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Sorteer op
</label>
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as 'score' | 'name')}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
>
<option value="score">Score (hoog laag)</option>
<option value="name">Naam (A Z)</option>
</select>
</div>
</div>
</div>
{/* Applications List */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200">
<div className="px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900">
Applicaties ({filteredApplications.length})
</h2>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Key
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Naam
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Team
</th>
<th className="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">
Overall Score
</th>
<th className="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">
General
</th>
<th className="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">
App. Management
</th>
<th className="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">
Velden
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{filteredApplications.length === 0 ? (
<tr>
<td colSpan={7} className="px-6 py-8 text-center text-gray-500">
Geen applicaties gevonden
</td>
</tr>
) : (
filteredApplications.map((app) => (
<tr key={app.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-900">
{app.key}
</td>
<td className="px-6 py-4 text-sm text-gray-900">
<Link
to={`/application/${app.id}/edit`}
className="text-blue-600 hover:text-blue-800 hover:underline"
>
{app.name}
</Link>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{app.team || '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-center">
<div className="flex items-center justify-center gap-2">
<span className={`text-sm font-bold ${getScoreColor(app.overallScore).split(' ')[0]}`}>
{app.overallScore.toFixed(1)}%
</span>
<div className="w-16 bg-gray-200 rounded-full h-2">
<div
className={`h-2 rounded-full ${getScoreBadgeColor(app.overallScore)}`}
style={{ width: `${app.overallScore}%` }}
></div>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-center">
<span className={`text-sm font-medium ${getScoreColor(app.generalScore).split(' ')[0]}`}>
{app.generalScore.toFixed(1)}%
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-center">
<span className={`text-sm font-medium ${getScoreColor(app.applicationManagementScore).split(' ')[0]}`}>
{app.applicationManagementScore.toFixed(1)}%
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-center text-sm text-gray-500">
{app.filledFields} / {app.totalFields}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
);
}

View File

@@ -89,6 +89,8 @@ function ObjectTypeCard({
isRefreshing,
refreshedCount,
refreshError,
cacheCount,
jiraCount,
}: {
objectType: SchemaObjectTypeDefinition;
isExpanded: boolean;
@@ -98,12 +100,15 @@ function ObjectTypeCard({
isRefreshing: boolean;
refreshedCount?: number;
refreshError?: string;
cacheCount?: number;
jiraCount?: number;
}) {
const referenceAttrs = objectType.attributes.filter(a => a.type === 'reference');
const nonReferenceAttrs = objectType.attributes.filter(a => a.type !== 'reference');
// Use refreshed count if available, otherwise use the original objectCount
const displayCount = refreshedCount ?? objectType.objectCount;
// Priority: refreshedCount > jiraCount > cacheCount > objectCount
// jiraCount is the actual count from Jira Assets API, which should match exactly
const displayCount = refreshedCount ?? jiraCount ?? cacheCount ?? objectType.objectCount;
return (
<div className="bg-white rounded-lg border border-gray-200 shadow-sm overflow-hidden">
@@ -630,6 +635,8 @@ export default function DataModelDashboard() {
isRefreshing={refreshingTypes.has(objectType.typeName)}
refreshedCount={refreshedCounts[objectType.typeName]}
refreshError={refreshErrors[objectType.typeName]}
cacheCount={schema.cacheCounts?.[objectType.typeName]}
jiraCount={schema.jiraCounts?.[objectType.typeName]}
/>
</div>
))}

View File

@@ -55,6 +55,11 @@ export function EffortDisplay({
const dynamicsFactor = breakdown?.dynamicsFactor ?? { value: 1.0, name: null };
const complexityFactor = breakdown?.complexityFactor ?? { value: 1.0, name: null };
// Calculate final min/max FTE by applying factors to base min/max
const factorMultiplier = numberOfUsersFactor.value * dynamicsFactor.value * complexityFactor.value;
const finalMinFTE = baseEffortMin !== null ? baseEffortMin * factorMultiplier : null;
const finalMaxFTE = baseEffortMax !== null ? baseEffortMax * factorMultiplier : null;
const governanceModelName = breakdown?.governanceModelName ?? breakdown?.governanceModel ?? null;
const applicationTypeName = breakdown?.applicationType ?? null;
const businessImpactAnalyse = breakdown?.businessImpactAnalyse ?? null;
@@ -128,6 +133,11 @@ export function EffortDisplay({
{/* Main FTE display */}
<div className="text-lg font-semibold text-gray-900">
{effectiveFte.toFixed(2)} FTE
{finalMinFTE !== null && finalMaxFTE !== null && finalMinFTE !== finalMaxFTE && (
<span className="text-xs text-gray-500 ml-1 font-normal">
(bandbreedte: {finalMinFTE.toFixed(2)} - {finalMaxFTE.toFixed(2)})
</span>
)}
{hasOverride && (
<span className="ml-2 text-sm font-normal text-orange-600">(Override)</span>
)}

View File

@@ -0,0 +1,406 @@
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
interface FunctionFTE {
function: {
code: string;
name: string;
description: string;
};
totalFTE: number;
minFTE: number;
maxFTE: number;
applicationCount: number;
applications: Array<{
id: string;
key: string;
name: string;
fte: number;
minFte: number | null;
maxFte: number | null;
}>;
}
interface DomainFTE {
domain: {
code: string;
name: string;
description: string;
};
totalFTE: number;
minFTE: number;
maxFTE: number;
applicationCount: number;
functions: FunctionFTE[];
applications: Array<{
id: string;
key: string;
name: string;
fte: number;
minFte: number | null;
maxFte: number | null;
}>;
}
interface FTEPerZiRADomainData {
overall: {
totalFTE: number;
totalMinFTE: number;
totalMaxFTE: number;
totalApplications: number;
domainCount: number;
};
byDomain: DomainFTE[];
}
const API_BASE = '/api';
export default function FTEPerZiRADomain() {
const [data, setData] = useState<FTEPerZiRADomainData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [expandedDomains, setExpandedDomains] = useState<Set<string>>(new Set());
const [expandedFunctions, setExpandedFunctions] = useState<Set<string>>(new Set());
useEffect(() => {
async function fetchData() {
try {
setLoading(true);
setError(null);
const response = await fetch(`${API_BASE}/dashboard/fte-per-zira-domain`);
if (!response.ok) {
throw new Error('Failed to fetch FTE per ZiRA Domain data');
}
const result = await response.json();
setData(result);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load data');
} finally {
setLoading(false);
}
}
fetchData();
}, []);
const toggleDomain = (domainCode: string) => {
setExpandedDomains(prev => {
const newSet = new Set(prev);
if (newSet.has(domainCode)) {
newSet.delete(domainCode);
} else {
newSet.add(domainCode);
}
return newSet;
});
};
const toggleFunction = (functionKey: string) => {
setExpandedFunctions(prev => {
const newSet = new Set(prev);
if (newSet.has(functionKey)) {
newSet.delete(functionKey);
} else {
newSet.add(functionKey);
}
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) {
return (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-red-700">
{error}
</div>
);
}
if (!data) {
return <div className="text-gray-500">Geen data beschikbaar</div>;
}
// Calculate max FTE for scaling the bars
const maxFTE = Math.max(...data.byDomain.map(d => d.totalFTE), 1);
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-gray-900">FTE per ZiRA Domain</h1>
<p className="mt-1 text-gray-500">
Welke business domeinen vereisen het meeste IT management effort?
</p>
</div>
{/* Overall Statistics */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="bg-white rounded-lg border border-gray-200 p-4">
<div className="text-sm text-gray-500 mb-1">Totaal FTE</div>
<div className="text-2xl font-bold text-gray-900">
{data.overall.totalFTE.toFixed(2)}
</div>
{data.overall.totalMinFTE > 0 && data.overall.totalMaxFTE > 0 && (
<div className="text-xs text-gray-500 mt-1">
Range: {data.overall.totalMinFTE.toFixed(2)} - {data.overall.totalMaxFTE.toFixed(2)} FTE
</div>
)}
</div>
<div className="bg-white rounded-lg border border-gray-200 p-4">
<div className="text-sm text-gray-500 mb-1">Aantal domeinen</div>
<div className="text-2xl font-bold text-blue-600">{data.overall.domainCount}</div>
</div>
<div className="bg-white rounded-lg border border-gray-200 p-4">
<div className="text-sm text-gray-500 mb-1">Totaal applicaties</div>
<div className="text-2xl font-bold text-green-600">{data.overall.totalApplications}</div>
</div>
<div className="bg-white rounded-lg border border-gray-200 p-4">
<div className="text-sm text-gray-500 mb-1">Gemiddeld FTE per domein</div>
<div className="text-2xl font-bold text-purple-600">
{data.overall.domainCount > 0
? (data.overall.totalFTE / data.overall.domainCount).toFixed(2)
: '0.00'}
</div>
</div>
</div>
{/* FTE by Domain - Bar Chart */}
<div className="bg-white rounded-lg border border-gray-200 p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">
FTE verdeling per ZiRA Domain en Applicatiefunctie
</h2>
<div className="space-y-4">
{data.byDomain.map(domain => {
const percentage = (domain.totalFTE / maxFTE) * 100;
const isDomainExpanded = expandedDomains.has(domain.domain.code);
const maxFunctionFTE = Math.max(...domain.functions.map(f => f.totalFTE), 1);
return (
<div key={domain.domain.code} className="border-b border-gray-100 last:border-b-0 pb-4 last:pb-0">
{/* Domain Header */}
<div className="flex items-center justify-between mb-2">
<div className="flex-1">
<div className="flex items-center gap-3 mb-1">
<button
onClick={() => toggleDomain(domain.domain.code)}
className="text-gray-500 hover:text-gray-700 transition-colors"
>
<svg
className={`w-5 h-5 transition-transform ${isDomainExpanded ? '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>
<h3 className="text-base font-semibold text-gray-900">
{domain.domain.name} ({domain.domain.code})
</h3>
</div>
<p className="text-sm text-gray-600 ml-8">{domain.domain.description}</p>
</div>
<div className="text-right ml-4">
<div className="text-lg font-bold text-gray-900">
{domain.totalFTE.toFixed(2)} FTE
</div>
{domain.minFTE > 0 && domain.maxFTE > 0 && (
<div className="text-xs text-gray-500">
{domain.minFTE.toFixed(2)} - {domain.maxFTE.toFixed(2)} FTE
</div>
)}
<div className="text-sm text-gray-500 mt-1">
{domain.applicationCount} applicatie{domain.applicationCount !== 1 ? 's' : ''}
</div>
<div className="text-xs text-gray-400 mt-1">
{domain.functions.length} functie{domain.functions.length !== 1 ? 's' : ''}
</div>
</div>
</div>
<div className="ml-8">
<div className="w-full bg-gray-200 rounded-full h-6 relative overflow-hidden">
<div
className="bg-blue-600 h-6 rounded-full transition-all duration-500 flex items-center justify-end pr-2"
style={{ width: `${percentage}%` }}
>
{percentage > 10 && (
<span className="text-white text-xs font-medium">
{domain.totalFTE.toFixed(2)} FTE
</span>
)}
</div>
{percentage <= 10 && (
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-700 text-xs font-medium">
{domain.totalFTE.toFixed(2)} FTE
</span>
)}
</div>
</div>
{/* Expanded view with functions */}
{isDomainExpanded && (
<div className="mt-4 ml-8 space-y-3">
{/* Functions within domain */}
{domain.functions.length > 0 && (
<div className="bg-gray-50 rounded-lg p-4">
<h4 className="text-sm font-semibold text-gray-700 mb-3">
Applicatiefuncties ({domain.functions.length})
</h4>
<div className="space-y-3">
{domain.functions.map(func => {
const funcPercentage = (func.totalFTE / maxFunctionFTE) * 100;
const functionKey = `${domain.domain.code}-${func.function.code}`;
const isFunctionExpanded = expandedFunctions.has(functionKey);
return (
<div key={func.function.code} className="bg-white rounded border border-gray-200 p-3">
<div className="flex items-center justify-between mb-2">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<button
onClick={() => toggleFunction(functionKey)}
className="text-gray-400 hover:text-gray-600 transition-colors"
>
<svg
className={`w-4 h-4 transition-transform ${isFunctionExpanded ? '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>
<span className="font-mono text-xs text-gray-500">{func.function.code}</span>
<h5 className="text-sm font-medium text-gray-900">{func.function.name}</h5>
</div>
{func.function.description && (
<p className="text-xs text-gray-600 ml-6">{func.function.description}</p>
)}
</div>
<div className="text-right ml-4">
<div className="text-sm font-semibold text-gray-900">
{func.totalFTE.toFixed(2)} FTE
</div>
{func.minFTE > 0 && func.maxFTE > 0 && (
<div className="text-xs text-gray-500">
{func.minFTE.toFixed(2)} - {func.maxFTE.toFixed(2)} FTE
</div>
)}
<div className="text-xs text-gray-500 mt-1">
{func.applicationCount} app{func.applicationCount !== 1 ? 's' : ''}
</div>
</div>
</div>
<div className="ml-6">
<div className="w-full bg-gray-200 rounded-full h-4 relative overflow-hidden">
<div
className="bg-green-600 h-4 rounded-full transition-all duration-500 flex items-center justify-end pr-1"
style={{ width: `${funcPercentage}%` }}
>
{funcPercentage > 15 && (
<span className="text-white text-xs font-medium">
{func.totalFTE.toFixed(2)}
</span>
)}
</div>
{funcPercentage <= 15 && (
<span className="absolute right-1 top-1/2 -translate-y-1/2 text-gray-700 text-xs font-medium">
{func.totalFTE.toFixed(2)}
</span>
)}
</div>
</div>
{/* Expanded view with applications for this function */}
{isFunctionExpanded && (
<div className="mt-3 ml-6 bg-gray-50 rounded p-3">
<h6 className="text-xs font-semibold text-gray-700 mb-2">
Applicaties ({func.applications.length})
</h6>
<div className="space-y-1">
{func.applications.map(app => (
<div
key={app.id}
className="flex items-center justify-between bg-white rounded border border-gray-200 p-2 hover:border-green-300 transition-colors"
>
<div className="flex-1">
<Link
to={`/application/${app.id}`}
className="text-blue-600 hover:text-blue-800 hover:underline text-xs font-medium"
>
{app.name}
</Link>
<span className="text-gray-400 ml-2 text-xs">({app.key})</span>
</div>
<div className="text-right ml-4">
<div className="text-xs font-semibold text-gray-900">
{app.fte.toFixed(2)} FTE
</div>
{app.minFte !== null && app.maxFte !== null && (
<div className="text-xs text-gray-500">
{app.minFte.toFixed(2)} - {app.maxFte.toFixed(2)}
</div>
)}
</div>
</div>
))}
</div>
</div>
)}
</div>
);
})}
</div>
</div>
)}
{/* All applications in domain (summary) */}
<div className="bg-gray-50 rounded-lg p-4">
<h4 className="text-sm font-semibold text-gray-700 mb-3">
Alle applicaties in dit domein ({domain.applications.length})
</h4>
<div className="space-y-2">
{domain.applications.map(app => (
<div
key={app.id}
className="flex items-center justify-between bg-white rounded border border-gray-200 p-3 hover:border-blue-300 transition-colors"
>
<div className="flex-1">
<Link
to={`/application/${app.id}`}
className="text-blue-600 hover:text-blue-800 hover:underline font-medium"
>
{app.name}
</Link>
<span className="text-gray-400 ml-2 text-xs">({app.key})</span>
</div>
<div className="text-right ml-4">
<div className="text-sm font-semibold text-gray-900">
{app.fte.toFixed(2)} FTE
</div>
{app.minFte !== null && app.maxFte !== null && (
<div className="text-xs text-gray-500">
{app.minFte.toFixed(2)} - {app.maxFte.toFixed(2)} FTE
</div>
)}
</div>
</div>
))}
</div>
</div>
</div>
)}
</div>
);
})}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,171 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import type { ReferenceValue } from '../types';
// Color scheme for governance models - matches exact names from Jira Assets
export 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
export function 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: '?' };
}
interface GovernanceModelBadgeProps {
governanceModelName: string | null | undefined;
governanceModelData?: ReferenceValue | null;
size?: 'sm' | 'md' | 'lg';
showPopup?: boolean;
className?: string;
}
export default function GovernanceModelBadge({
governanceModelName,
governanceModelData,
size = 'md',
showPopup = true,
className = '',
}: GovernanceModelBadgeProps) {
const [isHovered, setIsHovered] = useState(false);
const hoverTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const badgeRef = useRef<HTMLDivElement>(null);
const style = getGovernanceModelStyle(governanceModelName);
const name = governanceModelName || 'Niet ingesteld';
// Hover handlers with delayed hide to prevent flickering
const handleMouseEnter = useCallback(() => {
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
hoverTimeoutRef.current = null;
}
setIsHovered(true);
}, []);
const handleMouseLeave = useCallback(() => {
hoverTimeoutRef.current = setTimeout(() => {
setIsHovered(false);
}, 100);
}, []);
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
}
};
}, []);
// Size classes
const sizeClasses = {
sm: 'w-5 h-5 text-[8px]',
md: 'w-6 h-6 text-[10px]',
lg: 'w-8 h-8 text-xs',
};
const shouldShowPopup = showPopup && isHovered && name !== 'Niet ingesteld' && governanceModelData;
return (
<div className={`relative inline-block ${className}`} ref={badgeRef} style={{ zIndex: isHovered ? 10000 : 'auto' }}>
<div
className={`flex items-center justify-center rounded font-bold ${sizeClasses[size]} transition-all`}
style={{
backgroundColor: style.bg,
color: style.text,
}}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{style.letter}
</div>
{/* Hover popup */}
{shouldShowPopup && (
<div
className="absolute left-0 top-full mt-2 w-80 rounded-xl shadow-2xl border border-gray-200 p-4 text-left"
style={{
pointerEvents: 'auto',
backgroundColor: '#ffffff',
zIndex: 9999,
}}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{/* Arrow pointer */}
<div
className="absolute -top-2 left-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">
{governanceModelData.summary || name}
{governanceModelData.description && (
<span className="font-normal text-gray-500">
{' '}
({governanceModelData.description})
</span>
)}
</div>
{/* Remarks */}
{governanceModelData.remarks && (
<div className="text-xs text-gray-600 mb-3 whitespace-pre-wrap leading-relaxed">
{governanceModelData.remarks}
</div>
)}
{/* Application section */}
{governanceModelData.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">
{governanceModelData.application}
</div>
</div>
)}
{/* Fallback message if no data */}
{!governanceModelData.summary && !governanceModelData.remarks && !governanceModelData.application && (
<div className="text-xs text-gray-400 italic">
Geen aanvullende informatie beschikbaar
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -2038,41 +2038,110 @@ export default function GovernanceModelHelper() {
{/* AI Suggestion for BIA Classification */}
{aiSuggestion?.managementClassification?.biaClassification && (() => {
const aiValue = aiSuggestion.managementClassification.biaClassification.value;
const reasoning = aiSuggestion.managementClassification.biaClassification.reasoning || '';
const suggested = businessImpactAnalyses.find(
(b) => b.name === aiValue ||
b.name.includes(aiValue) ||
aiValue.includes(b.name)
);
const isAccepted = suggested && selectedBusinessImpactAnalyse?.objectId === suggested.objectId;
// Detect if this is from Excel or AI
const isFromExcel = reasoning.includes('BIA.xlsx') || reasoning.includes('Excel');
const isExactMatch = reasoning.includes('exacte match');
const isFuzzyMatch = reasoning.includes('fuzzy match');
// Extract match percentage if it's a fuzzy match
const matchPercentMatch = reasoning.match(/(\d+)% overeenkomst/);
const matchPercent = matchPercentMatch ? matchPercentMatch[1] : null;
// Extract Excel application name if available
const excelNameMatch = reasoning.match(/match met "([^"]+)"/);
const excelName = excelNameMatch ? excelNameMatch[1] : null;
// Determine styling based on source
const bgColor = isFromExcel ? 'bg-green-50' : 'bg-blue-50';
const borderColor = isFromExcel ? 'border-green-300' : 'border-blue-200';
const iconColor = isFromExcel ? 'text-green-600' : 'text-blue-600';
const badgeColor = isFromExcel
? (isExactMatch ? 'bg-green-100 text-green-800' : 'bg-green-100 text-green-700')
: 'bg-blue-100 text-blue-700';
return (
<div className="mt-2 p-2 bg-blue-50 border border-blue-200 rounded-lg">
<div className="flex items-center justify-between">
<div className={`mt-2 p-3 ${bgColor} border ${borderColor} rounded-lg`}>
<div className="flex items-start justify-between gap-3">
<div className="flex-1">
<p className="text-xs text-gray-600 mb-1">AI Suggestie:</p>
<p className="text-sm font-medium text-gray-900">{aiValue}</p>
{aiSuggestion.managementClassification.biaClassification.reasoning && (
<p className="text-xs text-gray-600 mt-1">{aiSuggestion.managementClassification.biaClassification.reasoning}</p>
{/* Source Badge */}
<div className="flex items-center gap-2 mb-2">
{isFromExcel ? (
<>
<svg className={`w-4 h-4 ${iconColor}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<span className={`text-xs font-medium px-2 py-0.5 rounded ${badgeColor}`}>
{isExactMatch ? 'Excel (Exact)' : isFuzzyMatch ? `Excel (Fuzzy ${matchPercent}%)` : 'Excel'}
</span>
</>
) : (
<>
<svg className={`w-4 h-4 ${iconColor}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
<span className={`text-xs font-medium px-2 py-0.5 rounded ${badgeColor}`}>
AI Suggestie
</span>
</>
)}
</div>
{/* Value */}
<p className="text-sm font-medium text-gray-900 mb-1">{aiValue}</p>
{/* Additional Info */}
{isFromExcel && excelName && (
<p className="text-xs text-gray-600 mb-1">
Match met: <span className="font-medium">{excelName}</span>
</p>
)}
{/* Reasoning */}
{reasoning && (
<p className="text-xs text-gray-600 mt-1">{reasoning}</p>
)}
{/* Warning for low-confidence fuzzy matches */}
{isFuzzyMatch && matchPercent && parseInt(matchPercent) < 75 && (
<div className="mt-2 flex items-start gap-1 text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded px-2 py-1">
<svg className="w-4 h-4 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span>Let op: Controleer of de match correct is ({matchPercent}% overeenkomst)</span>
</div>
)}
</div>
{/* Action Buttons */}
<div className="flex flex-col items-end gap-2">
{!isAccepted && suggested && (
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleAcceptAIField('biaClassification');
}}
className="btn btn-success text-xs whitespace-nowrap"
>
Accepteer
</button>
)}
{isAccepted && (
<span className="text-xs text-green-600 font-medium"> Geaccepteerd</span>
)}
{!suggested && (
<span className="text-xs text-orange-600"> Niet gevonden</span>
)}
</div>
{!isAccepted && suggested && (
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleAcceptAIField('biaClassification');
}}
className="btn btn-success text-xs ml-2 whitespace-nowrap"
>
Accepteer
</button>
)}
{isAccepted && (
<span className="text-xs text-green-600 font-medium ml-2"> Geaccepteerd</span>
)}
{!suggested && (
<span className="text-xs text-orange-600 ml-2"> Niet gevonden</span>
)}
</div>
</div>
);

View File

@@ -0,0 +1,336 @@
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
interface LifecycleApplication {
id: string;
key: string;
name: string;
status: string;
lifecycleStage: 'implementation' | 'poc' | 'production' | 'eos_eol' | 'deprecated' | 'shadow_undefined' | 'closed';
}
interface LifecycleData {
totalApplications: number;
byStage: {
implementation: number;
poc: number;
production: number;
eos_eol: number;
deprecated: number;
shadow_undefined: number;
closed: number;
};
applications: LifecycleApplication[];
}
const API_BASE = '/api';
// Lifecycle stages in order
const LIFECYCLE_STAGES = [
{ key: 'implementation', label: 'Implementation', status: 'Implementation', color: 'bg-blue-500' },
{ key: 'poc', label: 'Proof of Concept', status: 'Proof of Concept', color: 'bg-purple-500' },
{ key: 'production', label: 'In Production', status: 'In Production', color: 'bg-green-500' },
{ key: 'eos_eol', label: 'End of Support / End of Life', status: ['End of support', 'End of life'], color: 'bg-orange-500' },
{ key: 'deprecated', label: 'Deprecated', status: 'Deprecated', color: 'bg-amber-500' },
{ key: 'shadow_undefined', label: 'Shadow IT / Undefined', status: ['Shadow IT', 'Undefined'], color: 'bg-indigo-500' },
{ key: 'closed', label: 'Closed', status: 'Closed', color: 'bg-gray-500' },
] as const;
export default function LifecyclePipeline() {
const [data, setData] = useState<LifecycleData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedStage, setSelectedStage] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
useEffect(() => {
async function fetchData() {
setLoading(true);
setError(null);
try {
const response = await fetch(`${API_BASE}/dashboard/lifecycle-pipeline`);
if (!response.ok) {
throw new Error('Failed to fetch lifecycle pipeline data');
}
const result = await response.json();
setData(result);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load data');
} finally {
setLoading(false);
}
}
fetchData();
}, []);
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) {
return (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-red-700">
{error}
</div>
);
}
if (!data) {
return (
<div className="bg-gray-50 border border-gray-200 rounded-lg p-8 text-center">
<p className="text-gray-500">Geen data beschikbaar</p>
</div>
);
}
// Filter applications
const filteredApplications = data.applications.filter(app => {
const matchesSearch = !searchQuery ||
app.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
app.key.toLowerCase().includes(searchQuery.toLowerCase());
const matchesStage = !selectedStage || app.lifecycleStage === selectedStage;
return matchesSearch && matchesStage;
});
return (
<div>
{/* Header */}
<div className="mb-8">
<div className="flex items-center gap-3 mb-2">
<div className="p-2 bg-blue-100 rounded-lg">
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
</svg>
</div>
<h1 className="text-3xl font-bold text-gray-900">Lifecycle Pipeline</h1>
</div>
<p className="text-gray-600">
Overzicht van applicaties verdeeld over de verschillende lifecycle fases en statussen
</p>
</div>
{/* Total Summary Card */}
<div className="bg-gradient-to-r from-slate-700 to-slate-800 rounded-xl shadow-lg p-6 mb-6 text-white">
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium text-slate-300 mb-1">Totaal Applicaties</div>
<div className="text-4xl font-bold">{data.totalApplications}</div>
</div>
<div className="p-3 bg-white/10 rounded-lg">
<svg className="w-8 h-8" 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>
</div>
</div>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 xl:grid-cols-7 gap-4 mb-6">
{LIFECYCLE_STAGES.map(stage => {
const count = data.byStage[stage.key as keyof typeof data.byStage] || 0;
const percentage = data.totalApplications > 0
? Math.round((count / data.totalApplications) * 100)
: 0;
// Enhanced color gradients for each stage
const gradientClasses: Record<string, string> = {
implementation: 'from-blue-500 to-blue-600',
poc: 'from-purple-500 to-purple-600',
production: 'from-green-500 to-green-600',
eos_eol: 'from-orange-500 to-orange-600',
deprecated: 'from-amber-500 to-amber-600',
shadow_undefined: 'from-indigo-500 to-indigo-600',
closed: 'from-gray-500 to-gray-600',
};
return (
<div
key={stage.key}
className={`bg-gradient-to-br ${gradientClasses[stage.key]} rounded-xl shadow-md p-5 text-white cursor-pointer transition-all duration-200 hover:scale-105 hover:shadow-lg ${
selectedStage === stage.key ? 'ring-4 ring-offset-2 ring-gray-400 scale-105' : ''
}`}
onClick={() => setSelectedStage(selectedStage === stage.key ? null : stage.key)}
>
<div className="flex items-start justify-between mb-3">
<div className="text-sm font-medium opacity-95">{stage.label}</div>
{selectedStage === stage.key && (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
)}
</div>
<div className="text-4xl font-bold mb-1">{count}</div>
<div className="text-xs opacity-90 font-medium">{percentage}% van totaal</div>
{count > 0 && (
<div className="mt-3 pt-3 border-t border-white/20">
<div className="w-full bg-white/20 rounded-full h-1.5">
<div
className="bg-white rounded-full h-1.5 transition-all duration-300"
style={{ width: `${percentage}%` }}
/>
</div>
</div>
)}
</div>
);
})}
</div>
{/* Filters */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-5 mb-6">
<div className="flex items-center gap-2 mb-4">
<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="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
</svg>
<h3 className="text-sm font-semibold text-gray-700">Zoeken</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Search */}
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-2">
Zoek op naam of key
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg className="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Naam of key..."
className="w-full pl-10 pr-3 py-2 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
</div>
{(searchQuery || selectedStage) && (
<div className="mt-4 pt-4 border-t border-gray-200 flex items-center gap-2">
<button
onClick={() => {
setSearchQuery('');
setSelectedStage(null);
}}
className="text-sm text-blue-600 hover:text-blue-800 font-medium flex items-center gap-1"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
Filters wissen
</button>
{selectedStage && (
<span className="text-sm text-gray-600">
(Gefilterd op: {LIFECYCLE_STAGES.find(s => s.key === selectedStage)?.label})
</span>
)}
</div>
)}
</div>
{/* Applications List */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200 bg-gradient-to-r from-gray-50 to-white">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<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 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
<h2 className="text-lg font-semibold text-gray-900">
Applicaties
</h2>
<span className="px-2.5 py-0.5 text-xs font-medium bg-blue-100 text-blue-800 rounded-full">
{filteredApplications.length}
</span>
</div>
{filteredApplications.length !== data.totalApplications && (
<span className="text-sm text-gray-500">
Gefilterd van {data.totalApplications} totaal
</span>
)}
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Key
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Naam
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Lifecycle Fase
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{filteredApplications.length === 0 ? (
<tr>
<td colSpan={4} className="px-6 py-12 text-center">
<div className="flex flex-col items-center gap-2">
<svg className="w-12 h-12 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p className="text-gray-500 font-medium">Geen applicaties gevonden</p>
<p className="text-sm text-gray-400">Probeer andere filters</p>
</div>
</td>
</tr>
) : (
filteredApplications.map((app) => {
const stage = LIFECYCLE_STAGES.find(s => s.key === app.lifecycleStage);
return (
<tr key={app.id} className="hover:bg-blue-50 transition-colors">
<td className="px-6 py-4 whitespace-nowrap">
<span className="text-sm font-mono text-gray-600 bg-gray-100 px-2 py-1 rounded">
{app.key}
</span>
</td>
<td className="px-6 py-4">
<Link
to={`/application/${app.id}/edit`}
className="text-sm font-medium text-blue-600 hover:text-blue-800 hover:underline flex items-center gap-1 group"
>
{app.name}
<svg className="w-4 h-4 opacity-0 group-hover:opacity-100 transition-opacity" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</Link>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="text-sm text-gray-600">{app.status}</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{stage && (
<span
className={`inline-flex items-center px-3 py-1 text-xs font-semibold rounded-full ${stage.color} text-white shadow-sm`}
>
{stage.label}
</span>
)}
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
</div>
);
}

View File

@@ -68,6 +68,84 @@ export default function ReportsDashboard() {
color: 'cyan',
available: true,
},
{
id: 'technical-debt-heatmap',
title: 'Technical Debt Heatmap',
description: 'Visualisatie van applicaties met End of Life, End of Support of Deprecated status gecombineerd met BIA classificatie.',
icon: (
<svg className="w-6 h-6" 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>
),
href: '/reports/technical-debt-heatmap',
color: 'red',
available: true,
},
{
id: 'lifecycle-pipeline',
title: 'Lifecycle Pipeline',
description: 'Funnel/timeline visualisatie van applicaties door de verschillende lifecycle fases: Implementation → PoC → Production → EoS → EoL',
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
</svg>
),
href: '/reports/lifecycle-pipeline',
color: 'indigo',
available: true,
},
{
id: 'data-completeness',
title: 'Data Completeness Score',
description: 'Percentage van verplichte velden ingevuld per applicatie, per team en overall.',
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
href: '/reports/data-completeness',
color: 'teal',
available: true,
},
{
id: 'zira-domain-coverage',
title: 'ZiRA Domain Coverage',
description: 'Welke ZiRA functies zijn goed ondersteund versus gaps in IT coverage.',
icon: (
<svg className="w-6 h-6" 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>
),
href: '/reports/zira-domain-coverage',
color: 'emerald',
available: true,
},
{
id: 'fte-per-zira-domain',
title: 'FTE per ZiRA Domain',
description: 'Welke business domeinen vereisen het meeste IT management effort?',
icon: (
<svg className="w-6 h-6" 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>
),
href: '/reports/fte-per-zira-domain',
color: 'amber',
available: true,
},
{
id: 'complexity-dynamics-bubble',
title: 'Complexity vs Dynamics Bubble Chart',
description: 'X=Complexity, Y=Dynamics, Size=FTE, Color=BIA',
icon: (
<svg className="w-6 h-6" 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>
),
href: '/reports/complexity-dynamics-bubble',
color: 'pink',
available: true,
},
];
const colorClasses = {
@@ -101,6 +179,42 @@ export default function ReportsDashboard() {
iconText: 'text-cyan-600',
hover: 'hover:bg-cyan-100',
},
red: {
bg: 'bg-red-50',
iconBg: 'bg-red-100',
iconText: 'text-red-600',
hover: 'hover:bg-red-100',
},
indigo: {
bg: 'bg-indigo-50',
iconBg: 'bg-indigo-100',
iconText: 'text-indigo-600',
hover: 'hover:bg-indigo-100',
},
teal: {
bg: 'bg-teal-50',
iconBg: 'bg-teal-100',
iconText: 'text-teal-600',
hover: 'hover:bg-teal-100',
},
emerald: {
bg: 'bg-emerald-50',
iconBg: 'bg-emerald-100',
iconText: 'text-emerald-600',
hover: 'hover:bg-emerald-100',
},
amber: {
bg: 'bg-amber-50',
iconBg: 'bg-amber-100',
iconText: 'text-amber-600',
hover: 'hover:bg-amber-100',
},
pink: {
bg: 'bg-pink-50',
iconBg: 'bg-pink-100',
iconText: 'text-pink-600',
hover: 'hover:bg-pink-100',
},
};
return (

View File

@@ -0,0 +1,314 @@
import { useEffect, useState } from 'react';
import { Radar, RadarChart, PolarGrid, PolarAngleAxis, PolarRadiusAxis, Legend, ResponsiveContainer } from 'recharts';
import { getTeamPortfolioHealth, type TeamPortfolioHealthData } from '../services/api';
import type { ApplicationStatus } from '../types';
const ALL_STATUSES: ApplicationStatus[] = [
'In Production',
'Implementation',
'Proof of Concept',
'End of support',
'End of life',
'Deprecated',
'Shadow IT',
'Closed',
'Undefined',
];
export default function TeamPortfolioHealth() {
const [data, setData] = useState<TeamPortfolioHealthData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [excludedStatuses, setExcludedStatuses] = useState<ApplicationStatus[]>(['Closed', 'Deprecated']);
const [selectedTeams, setSelectedTeams] = useState<Set<string>>(new Set());
useEffect(() => {
async function fetchData() {
setLoading(true);
setError(null);
try {
const result = await getTeamPortfolioHealth(excludedStatuses);
if (result && result.teams) {
setData(result);
// Select all teams by default
setSelectedTeams(new Set(result.teams.map(t => t.team?.objectId || 'unassigned')));
} else {
setError('Invalid data received from server');
}
} catch (err) {
console.error('Error fetching team portfolio health:', err);
setError(err instanceof Error ? err.message : 'Failed to load team portfolio health data');
} finally {
setLoading(false);
}
}
fetchData();
}, [excludedStatuses]);
const toggleTeam = (teamId: string) => {
setSelectedTeams(prev => {
const newSet = new Set(prev);
if (newSet.has(teamId)) {
newSet.delete(teamId);
} else {
newSet.add(teamId);
}
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) {
return (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-red-700">
{error}
</div>
);
}
if (!data || data.teams.length === 0) {
return (
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4 text-gray-700">
Geen team data beschikbaar.
</div>
);
}
// Generate colors for teams
const teamColors: Record<string, string> = {};
const colors = [
'#3B82F6', // blue
'#10B981', // green
'#F59E0B', // amber
'#EF4444', // red
'#8B5CF6', // purple
'#EC4899', // pink
'#06B6D4', // cyan
'#F97316', // orange
];
data.teams.forEach((team, index) => {
const teamId = team.team?.objectId || 'unassigned';
teamColors[teamId] = colors[index % colors.length];
});
// Prepare data for radar chart - recharts expects array of objects with all metrics
const selectedTeamsList = data.teams.filter(t => selectedTeams.has(t.team?.objectId || 'unassigned'));
// Only create radar data if we have selected teams
const radarData = selectedTeamsList.length > 0 ? [
{
metric: 'Complexity',
...Object.fromEntries(
selectedTeamsList.map(team => {
const teamName = team.team?.name || 'Niet toegewezen';
const value = Math.round((team.metrics?.complexity || 0) * 100);
return [teamName, value];
})
),
},
{
metric: 'Dynamics',
...Object.fromEntries(
selectedTeamsList.map(team => {
const teamName = team.team?.name || 'Niet toegewezen';
const value = Math.round((team.metrics?.dynamics || 0) * 100);
return [teamName, value];
})
),
},
{
metric: 'BIA',
...Object.fromEntries(
selectedTeamsList.map(team => {
const teamName = team.team?.name || 'Niet toegewezen';
const value = Math.round((team.metrics?.bia || 0) * 100);
return [teamName, value];
})
),
},
{
metric: 'Governance Maturity',
...Object.fromEntries(
selectedTeamsList.map(team => {
const teamName = team.team?.name || 'Niet toegewezen';
const value = Math.round((team.metrics?.governanceMaturity || 0) * 100);
return [teamName, value];
})
),
},
] : [];
return (
<div>
{/* Header */}
<div className="mb-8">
<h1 className="text-2xl font-bold text-gray-900">Team Portfolio Health</h1>
<p className="mt-1 text-gray-500">
Radar chart met complexiteit, dynamics, BIA en governance maturity per team.
</p>
</div>
{/* Filters */}
<div className="mb-6 space-y-4">
{/* Status Filter */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Uitgesloten statussen
</label>
<div className="flex flex-wrap gap-2">
{ALL_STATUSES.map(status => (
<label key={status} className="flex items-center">
<input
type="checkbox"
checked={excludedStatuses.includes(status)}
onChange={(e) => {
if (e.target.checked) {
setExcludedStatuses([...excludedStatuses, status]);
} else {
setExcludedStatuses(excludedStatuses.filter(s => s !== status));
}
}}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="ml-2 text-sm text-gray-700">{status}</span>
</label>
))}
</div>
</div>
{/* Team Selection */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Selecteer teams ({selectedTeams.size} van {data.teams.length})
</label>
<div className="flex flex-wrap gap-2">
{data.teams.map(team => {
const teamId = team.team?.objectId || 'unassigned';
const teamName = team.team?.name || 'Niet toegewezen';
const isSelected = selectedTeams.has(teamId);
return (
<button
key={teamId}
onClick={() => toggleTeam(teamId)}
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
isSelected
? 'bg-blue-100 text-blue-700 border border-blue-300'
: 'bg-gray-100 text-gray-700 border border-gray-300 hover:bg-gray-200'
}`}
>
{teamName} ({team.applicationCount})
</button>
);
})}
</div>
</div>
</div>
{/* Radar Chart */}
<div className="bg-white rounded-lg border border-gray-200 p-6 mb-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Radar Chart</h2>
{selectedTeams.size === 0 ? (
<div className="text-center text-gray-500 py-8">
Selecteer ten minste één team om de radar chart te bekijken.
</div>
) : (
<ResponsiveContainer width="100%" height={500}>
<RadarChart data={radarData}>
<PolarGrid />
<PolarAngleAxis
dataKey="metric"
tick={{ fill: '#374151', fontSize: 12 }}
/>
<PolarRadiusAxis
angle={90}
domain={[0, 100]}
tick={{ fill: '#6B7280', fontSize: 10 }}
/>
{selectedTeamsList.map((team, index) => {
const teamId = team.team?.objectId || 'unassigned';
const teamName = team.team?.name || 'Niet toegewezen';
const color = teamColors[teamId] || colors[index % colors.length];
return (
<Radar
key={teamId}
name={teamName}
dataKey={teamName}
stroke={color}
fill={color}
fillOpacity={0.3}
strokeWidth={2}
/>
);
})}
<Legend />
</RadarChart>
</ResponsiveContainer>
)}
</div>
{/* Metrics Table */}
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
<h2 className="text-lg font-semibold text-gray-900 p-4 border-b border-gray-200">
Metrics Overzicht
</h2>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Team
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Applicaties
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Complexity
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Dynamics
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
BIA
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Governance Maturity
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{selectedTeamsList.map(team => (
<tr key={team.team?.objectId || 'unassigned'}>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{team.team?.name || 'Niet toegewezen'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{team.applicationCount}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{Math.round(team.metrics.complexity * 100)}%
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{Math.round(team.metrics.dynamics * 100)}%
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{Math.round(team.metrics.bia * 100)}%
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{Math.round(team.metrics.governanceMaturity * 100)}%
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,469 @@
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
interface TechnicalDebtApplication {
id: string;
key: string;
name: string;
status: string | null;
businessImpactAnalyse: string | null;
riskLevel: 'critical' | 'high' | 'medium' | 'low';
}
interface TechnicalDebtData {
totalApplications: number;
applications: TechnicalDebtApplication[];
byStatus: Record<string, number>;
byBIA: Record<string, number>;
byRiskLevel: Record<string, number>;
}
const API_BASE = '/api';
// BIA levels ordered by impact (F = highest, A = lowest)
const BIA_LEVELS = ['F', 'E', 'D', 'C', 'B', 'A'];
const BIA_LABELS: Record<string, string> = {
'F': 'F - Levensbedreigend',
'E': 'E - Kritiek zorgondersteunend',
'D': 'D - Belangrijke zorgprocessen',
'C': 'C - Standaard bedrijfsvoering',
'B': 'B - Ondersteunende tooling',
'A': 'A - Test/ontwikkelomgeving',
};
// Status levels ordered by severity
const STATUS_LEVELS = ['End of life', 'End of support', 'Deprecated'];
const STATUS_LABELS: Record<string, string> = {
'End of life': 'End of Life',
'End of support': 'End of Support',
'Deprecated': 'Deprecated',
};
// Risk calculation: High BIA (F, E, D) + EOL/EOS/Deprecated = critical risk
function calculateRiskLevel(status: string | null, bia: string | null): 'critical' | 'high' | 'medium' | 'low' {
if (!status || !bia) return 'low';
const isHighBIA = ['F', 'E', 'D'].includes(bia);
const isEOL = status === 'End of life';
const isEOS = status === 'End of support';
const isDeprecated = status === 'Deprecated';
if (isHighBIA && (isEOL || isEOS || isDeprecated)) {
if (isEOL && ['F', 'E'].includes(bia)) return 'critical';
if (isEOL || (isEOS && bia === 'F')) return 'critical';
if (isHighBIA) return 'high';
}
if (isEOL || isEOS) return 'high';
if (isDeprecated) return 'medium';
return 'low';
}
function getRiskColor(riskLevel: string): string {
switch (riskLevel) {
case 'critical':
return 'bg-red-600 text-white';
case 'high':
return 'bg-orange-500 text-white';
case 'medium':
return 'bg-yellow-400 text-gray-900';
case 'low':
return 'bg-gray-200 text-gray-700';
default:
return 'bg-gray-100 text-gray-600';
}
}
function getRiskLabel(riskLevel: string): string {
switch (riskLevel) {
case 'critical':
return 'Kritiek';
case 'high':
return 'Hoog';
case 'medium':
return 'Gemiddeld';
case 'low':
return 'Laag';
default:
return 'Onbekend';
}
}
export default function TechnicalDebtHeatmap() {
const [data, setData] = useState<TechnicalDebtData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [selectedStatus, setSelectedStatus] = useState<string | null>(null);
const [selectedBIA, setSelectedBIA] = useState<string | null>(null);
const [selectedRiskLevel, setSelectedRiskLevel] = useState<string | null>(null);
useEffect(() => {
async function fetchData() {
setLoading(true);
setError(null);
try {
const response = await fetch(`${API_BASE}/dashboard/technical-debt`);
if (!response.ok) {
throw new Error('Failed to fetch technical debt data');
}
const result = await response.json();
setData(result);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load data');
} finally {
setLoading(false);
}
}
fetchData();
}, []);
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) {
return (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-red-700">
{error}
</div>
);
}
if (!data) {
return (
<div className="bg-gray-50 border border-gray-200 rounded-lg p-8 text-center">
<p className="text-gray-500">Geen data beschikbaar</p>
</div>
);
}
// Filter applications based on search and filters
const filteredApplications = data.applications.filter(app => {
const matchesSearch = !searchQuery ||
app.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
app.key.toLowerCase().includes(searchQuery.toLowerCase());
const matchesStatus = !selectedStatus || app.status === selectedStatus;
const matchesBIA = !selectedBIA || app.businessImpactAnalyse === selectedBIA;
const matchesRisk = !selectedRiskLevel || app.riskLevel === selectedRiskLevel;
return matchesSearch && matchesStatus && matchesBIA && matchesRisk;
});
// Build heatmap data: status x BIA matrix
const heatmapData: Record<string, Record<string, number>> = {};
STATUS_LEVELS.forEach(status => {
heatmapData[status] = {};
BIA_LEVELS.forEach(bia => {
heatmapData[status][bia] = 0;
});
});
data.applications.forEach(app => {
if (app.status && app.businessImpactAnalyse && heatmapData[app.status]) {
heatmapData[app.status][app.businessImpactAnalyse] =
(heatmapData[app.status][app.businessImpactAnalyse] || 0) + 1;
}
});
// Get max count for color intensity
const maxCount = Math.max(
...Object.values(heatmapData).flatMap(row => Object.values(row))
);
return (
<div>
{/* Header */}
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900">Technical Debt Heatmap</h1>
<p className="mt-1 text-sm text-gray-500">
Visualisatie van applicaties met End of Life, End of Support of Deprecated status gecombineerd met BIA classificatie.
Hoge BIA + EOL = kritiek risico.
</p>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div className="text-sm text-gray-500 mb-1">Totaal Applicaties</div>
<div className="text-2xl font-bold text-gray-900">{data.totalApplications}</div>
</div>
<div className="bg-red-50 rounded-lg shadow-sm border border-red-200 p-4">
<div className="text-sm text-red-600 mb-1">Kritiek Risico</div>
<div className="text-2xl font-bold text-red-700">{data.byRiskLevel.critical || 0}</div>
</div>
<div className="bg-orange-50 rounded-lg shadow-sm border border-orange-200 p-4">
<div className="text-sm text-orange-600 mb-1">Hoog Risico</div>
<div className="text-2xl font-bold text-orange-700">{data.byRiskLevel.high || 0}</div>
</div>
<div className="bg-yellow-50 rounded-lg shadow-sm border border-yellow-200 p-4">
<div className="text-sm text-yellow-600 mb-1">Gemiddeld Risico</div>
<div className="text-2xl font-bold text-yellow-700">{data.byRiskLevel.medium || 0}</div>
</div>
</div>
{/* Filters */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4 mb-6">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{/* Search */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Zoeken
</label>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Naam of key..."
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
{/* Status Filter */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Status
</label>
<select
value={selectedStatus || ''}
onChange={(e) => setSelectedStatus(e.target.value || null)}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
>
<option value="">Alle statussen</option>
{STATUS_LEVELS.map(status => (
<option key={status} value={status}>
{STATUS_LABELS[status]} ({data.byStatus[status] || 0})
</option>
))}
</select>
</div>
{/* BIA Filter */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
BIA Classificatie
</label>
<select
value={selectedBIA || ''}
onChange={(e) => setSelectedBIA(e.target.value || null)}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
>
<option value="">Alle BIA niveaus</option>
{BIA_LEVELS.map(bia => (
<option key={bia} value={bia}>
{BIA_LABELS[bia]} ({data.byBIA[bia] || 0})
</option>
))}
</select>
</div>
{/* Risk Level Filter */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Risico Niveau
</label>
<select
value={selectedRiskLevel || ''}
onChange={(e) => setSelectedRiskLevel(e.target.value || null)}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
>
<option value="">Alle risico niveaus</option>
<option value="critical">Kritiek ({data.byRiskLevel.critical || 0})</option>
<option value="high">Hoog ({data.byRiskLevel.high || 0})</option>
<option value="medium">Gemiddeld ({data.byRiskLevel.medium || 0})</option>
<option value="low">Laag ({data.byRiskLevel.low || 0})</option>
</select>
</div>
</div>
</div>
{/* Heatmap */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Heatmap: Status × BIA Classificatie</h2>
<div className="overflow-x-auto">
<table className="w-full border-collapse">
<thead>
<tr>
<th className="border border-gray-300 bg-gray-50 px-4 py-2 text-left text-sm font-medium text-gray-700">
Status \ BIA
</th>
{BIA_LEVELS.map(bia => (
<th
key={bia}
className="border border-gray-300 bg-gray-50 px-4 py-2 text-center text-sm font-medium text-gray-700 min-w-[120px]"
>
{BIA_LABELS[bia]}
</th>
))}
<th className="border border-gray-300 bg-gray-50 px-4 py-2 text-center text-sm font-medium text-gray-700">
Totaal
</th>
</tr>
</thead>
<tbody>
{STATUS_LEVELS.map(status => {
const rowTotal = BIA_LEVELS.reduce(
(sum, bia) => sum + (heatmapData[status][bia] || 0),
0
);
return (
<tr key={status}>
<td className="border border-gray-300 bg-gray-50 px-4 py-2 text-sm font-medium text-gray-700">
{STATUS_LABELS[status]}
</td>
{BIA_LEVELS.map(bia => {
const count = heatmapData[status][bia] || 0;
const intensity = maxCount > 0 ? count / maxCount : 0;
const bgColor = intensity > 0.7
? 'bg-red-600'
: intensity > 0.4
? 'bg-orange-500'
: intensity > 0.1
? 'bg-yellow-400'
: 'bg-gray-100';
const textColor = intensity > 0.1 ? 'text-white' : 'text-gray-700';
return (
<td
key={bia}
className={`border border-gray-300 px-4 py-2 text-center text-sm font-bold ${bgColor} ${textColor}`}
title={`${count} applicatie(s)`}
>
{count > 0 ? count : '-'}
</td>
);
})}
<td className="border border-gray-300 bg-gray-50 px-4 py-2 text-center text-sm font-bold text-gray-900">
{rowTotal}
</td>
</tr>
);
})}
<tr>
<td className="border border-gray-300 bg-gray-100 px-4 py-2 text-sm font-bold text-gray-900">
Totaal
</td>
{BIA_LEVELS.map(bia => {
const colTotal = STATUS_LEVELS.reduce(
(sum, status) => sum + (heatmapData[status][bia] || 0),
0
);
return (
<td
key={bia}
className="border border-gray-300 bg-gray-100 px-4 py-2 text-center text-sm font-bold text-gray-900"
>
{colTotal}
</td>
);
})}
<td className="border border-gray-300 bg-gray-100 px-4 py-2 text-center text-sm font-bold text-gray-900">
{data.totalApplications}
</td>
</tr>
</tbody>
</table>
</div>
<div className="mt-4 flex items-center gap-4 text-xs text-gray-500">
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-red-600"></div>
<span>Hoog (70%)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-orange-500"></div>
<span>Gemiddeld (40-70%)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-yellow-400"></div>
<span>Laag (10-40%)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-gray-100"></div>
<span>Geen data</span>
</div>
</div>
</div>
{/* Applications List */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200">
<div className="px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900">
Applicaties ({filteredApplications.length})
</h2>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Key
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Naam
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
BIA
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Risico
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{filteredApplications.length === 0 ? (
<tr>
<td colSpan={5} className="px-6 py-8 text-center text-gray-500">
Geen applicaties gevonden
</td>
</tr>
) : (
filteredApplications.map((app) => (
<tr key={app.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-900">
{app.key}
</td>
<td className="px-6 py-4 text-sm text-gray-900">
<Link
to={`/application/${app.id}/edit`}
className="text-blue-600 hover:text-blue-800 hover:underline"
>
{app.name}
</Link>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{app.status || '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{app.businessImpactAnalyse
? `${app.businessImpactAnalyse} - ${BIA_LABELS[app.businessImpactAnalyse] || app.businessImpactAnalyse}`
: '-'
}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getRiskColor(app.riskLevel)}`}
>
{getRiskLabel(app.riskLevel)}
</span>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,378 @@
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
interface ReferenceValue {
objectId: string;
key: string;
name: string;
}
interface FunctionCoverage {
functionId: string;
functionKey: string;
functionName: string;
functionDescription: string | null;
category: ReferenceValue | null;
applicationCount: number;
applications: Array<{ id: string; key: string; name: string }>;
coverageStatus: 'gap' | 'low' | 'medium' | 'well-supported';
}
interface CategoryCoverage {
category: ReferenceValue;
functions: FunctionCoverage[];
totalFunctions: number;
coveredFunctions: number;
gapFunctions: number;
}
interface ZiRADomainCoverageData {
overall: {
totalFunctions: number;
coveredFunctions: number;
gapFunctions: number;
lowCoverageFunctions: number;
mediumCoverageFunctions: number;
wellSupportedFunctions: number;
coveragePercentage: number;
};
byCategory: CategoryCoverage[];
allFunctions: FunctionCoverage[];
}
const API_BASE = '/api';
function getCoverageStatusColor(status: string): string {
switch (status) {
case 'well-supported':
return 'bg-green-100 text-green-800 border-green-300';
case 'medium':
return 'bg-blue-100 text-blue-800 border-blue-300';
case 'low':
return 'bg-yellow-100 text-yellow-800 border-yellow-300';
case 'gap':
return 'bg-red-100 text-red-800 border-red-300';
default:
return 'bg-gray-100 text-gray-800 border-gray-300';
}
}
function getCoverageStatusLabel(status: string): string {
switch (status) {
case 'well-supported':
return 'Goed ondersteund';
case 'medium':
return 'Gemiddeld';
case 'low':
return 'Laag';
case 'gap':
return 'Gap';
default:
return 'Onbekend';
}
}
export default function ZiRADomainCoverage() {
const [data, setData] = useState<ZiRADomainCoverageData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
const [selectedStatus, setSelectedStatus] = useState<string | null>(null);
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set());
useEffect(() => {
async function fetchData() {
try {
setLoading(true);
setError(null);
const response = await fetch(`${API_BASE}/dashboard/zira-domain-coverage`);
if (!response.ok) {
throw new Error('Failed to fetch ZiRA domain coverage data');
}
const result = await response.json();
setData(result);
// Expand all categories by default
if (result.byCategory) {
setExpandedCategories(new Set(result.byCategory.map((cat: CategoryCoverage) => cat.category.objectId)));
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load data');
} finally {
setLoading(false);
}
}
fetchData();
}, []);
const toggleCategory = (categoryId: string) => {
setExpandedCategories(prev => {
const newSet = new Set(prev);
if (newSet.has(categoryId)) {
newSet.delete(categoryId);
} else {
newSet.add(categoryId);
}
return newSet;
});
};
// Filter functions based on search and filters
const filteredCategories = data?.byCategory
? data.byCategory.map(category => {
let filteredFunctions = category.functions;
// Filter by search query
if (searchQuery) {
const query = searchQuery.toLowerCase();
filteredFunctions = filteredFunctions.filter(
f =>
f.functionName.toLowerCase().includes(query) ||
f.functionKey.toLowerCase().includes(query) ||
(f.functionDescription && f.functionDescription.toLowerCase().includes(query))
);
}
// Filter by category
if (selectedCategory && category.category.objectId !== selectedCategory) {
filteredFunctions = [];
}
// Filter by status
if (selectedStatus) {
filteredFunctions = filteredFunctions.filter(f => f.coverageStatus === selectedStatus);
}
return {
...category,
functions: filteredFunctions,
};
})
: [];
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) {
return (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-red-700">
{error}
</div>
);
}
if (!data) {
return <div className="text-gray-500">Geen data beschikbaar</div>;
}
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-gray-900">ZiRA Domain Coverage</h1>
<p className="mt-1 text-gray-500">
Analyse van welke ZiRA functies goed ondersteund worden versus gaps in IT coverage
</p>
</div>
{/* Overall Statistics */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="bg-white rounded-lg border border-gray-200 p-4">
<div className="text-sm text-gray-500 mb-1">Totaal functies</div>
<div className="text-2xl font-bold text-gray-900">{data.overall.totalFunctions}</div>
</div>
<div className="bg-white rounded-lg border border-gray-200 p-4">
<div className="text-sm text-gray-500 mb-1">Ondersteund</div>
<div className="text-2xl font-bold text-green-600">{data.overall.coveredFunctions}</div>
<div className="text-xs text-gray-500 mt-1">
{data.overall.coveragePercentage.toFixed(1)}% coverage
</div>
</div>
<div className="bg-white rounded-lg border border-gray-200 p-4">
<div className="text-sm text-gray-500 mb-1">Gaps</div>
<div className="text-2xl font-bold text-red-600">{data.overall.gapFunctions}</div>
<div className="text-xs text-gray-500 mt-1">
Geen applicaties
</div>
</div>
<div className="bg-white rounded-lg border border-gray-200 p-4">
<div className="text-sm text-gray-500 mb-1">Goed ondersteund</div>
<div className="text-2xl font-bold text-blue-600">{data.overall.wellSupportedFunctions}</div>
<div className="text-xs text-gray-500 mt-1">
10 applicaties
</div>
</div>
</div>
{/* Filters */}
<div className="bg-white rounded-lg border border-gray-200 p-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Zoeken</label>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Zoek op functie naam of key..."
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Categorie</label>
<select
value={selectedCategory || ''}
onChange={(e) => setSelectedCategory(e.target.value || null)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="">Alle categorieën</option>
{data.byCategory.map(cat => (
<option key={cat.category.objectId} value={cat.category.objectId}>
{cat.category.name} ({cat.totalFunctions})
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Coverage status</label>
<select
value={selectedStatus || ''}
onChange={(e) => setSelectedStatus(e.target.value || null)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="">Alle statussen</option>
<option value="well-supported">Goed ondersteund (10)</option>
<option value="medium">Gemiddeld (3-9)</option>
<option value="low">Laag (1-2)</option>
<option value="gap">Gap (0)</option>
</select>
</div>
</div>
</div>
{/* Coverage by Category */}
<div className="space-y-4">
{filteredCategories.map(category => {
const isExpanded = expandedCategories.has(category.category.objectId);
const hasFunctions = category.functions.length > 0;
if (!hasFunctions) return null;
return (
<div key={category.category.objectId} className="bg-white rounded-lg border border-gray-200 overflow-hidden">
{/* Category Header */}
<div
onClick={() => toggleCategory(category.category.objectId)}
className="px-6 py-4 bg-gray-50 border-b border-gray-200 cursor-pointer hover:bg-gray-100 transition-colors"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<svg
className={`w-5 h-5 text-gray-500 transition-transform ${isExpanded ? '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>
<h3 className="text-lg font-semibold text-gray-900">{category.category.name}</h3>
<span className="text-sm text-gray-500">
({category.functions.length} van {category.totalFunctions})
</span>
</div>
<div className="flex items-center gap-4 text-sm">
<span className="text-green-600 font-medium">
{category.coveredFunctions} ondersteund
</span>
<span className="text-red-600 font-medium">
{category.gapFunctions} gaps
</span>
</div>
</div>
</div>
{/* Category Functions */}
{isExpanded && (
<div className="divide-y divide-gray-100">
{category.functions.map(func => (
<div key={func.functionId} className="px-6 py-4 hover:bg-gray-50">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-1">
<span className="font-mono text-xs text-gray-500">{func.functionKey}</span>
<h4 className="font-medium text-gray-900">{func.functionName}</h4>
<span
className={`px-2 py-0.5 rounded-full text-xs font-medium border ${getCoverageStatusColor(
func.coverageStatus
)}`}
>
{getCoverageStatusLabel(func.coverageStatus)}
</span>
</div>
{func.functionDescription && (
<p className="text-sm text-gray-600 mt-1">{func.functionDescription}</p>
)}
<div className="mt-2 flex items-center gap-4 text-sm text-gray-500">
<span>
<span className="font-medium text-gray-900">{func.applicationCount}</span> applicatie{func.applicationCount !== 1 ? 's' : ''}
</span>
{func.applications.length > 0 && (
<details className="inline-block">
<summary className="cursor-pointer text-blue-600 hover:text-blue-800">
Bekijk applicaties
</summary>
<div className="mt-2 pl-4 border-l-2 border-gray-200 space-y-1">
{func.applications.map(app => (
<div key={app.id}>
<Link
to={`/application/${app.id}`}
className="text-blue-600 hover:text-blue-800 hover:underline"
>
{app.name}
</Link>
<span className="text-gray-400 ml-2 text-xs">({app.key})</span>
</div>
))}
</div>
</details>
)}
</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
})}
</div>
{/* Summary of Gaps */}
{data.overall.gapFunctions > 0 && (
<div className="bg-red-50 border border-red-200 rounded-lg p-6">
<h3 className="text-lg font-semibold text-red-900 mb-4">
Functies zonder IT ondersteuning ({data.overall.gapFunctions})
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{data.allFunctions
.filter(f => f.coverageStatus === 'gap')
.map(func => (
<div key={func.functionId} className="bg-white rounded border border-red-200 p-3">
<div className="font-medium text-gray-900">{func.functionName}</div>
<div className="text-xs text-gray-500 mt-1">{func.functionKey}</div>
{func.category && (
<div className="text-xs text-gray-400 mt-1">Categorie: {func.category.name}</div>
)}
</div>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -10,6 +10,8 @@ import type {
TeamDashboardData,
ApplicationStatus,
EffortCalculationBreakdown,
BIAComparisonResponse,
BusinessImportanceComparisonResponse,
} from '../types';
const API_BASE = '/api';
@@ -426,6 +428,30 @@ export async function getTeamDashboardData(excludedStatuses: ApplicationStatus[]
return fetchApi<TeamDashboardData>(`/applications/team-dashboard?${queryString}`);
}
// =============================================================================
// Team Portfolio Health
// =============================================================================
export interface TeamPortfolioHealthData {
teams: Array<{
team: ReferenceValue | null;
metrics: {
complexity: number;
dynamics: number;
bia: number;
governanceMaturity: number;
};
applicationCount: number;
}>;
}
export async function getTeamPortfolioHealth(excludedStatuses: ApplicationStatus[] = []): Promise<TeamPortfolioHealthData> {
const params = new URLSearchParams();
params.append('excludedStatuses', excludedStatuses.join(','));
const queryString = params.toString();
return fetchApi<TeamPortfolioHealthData>(`/applications/team-portfolio-health?${queryString}`);
}
// =============================================================================
// Configuration
// =============================================================================
@@ -676,8 +702,64 @@ export interface SchemaResponse {
totalAttributes: number;
};
objectTypes: Record<string, SchemaObjectTypeDefinition>;
cacheCounts?: Record<string, number>; // Cache counts by type name (from objectsByType)
jiraCounts?: Record<string, number>; // Actual counts from Jira Assets API
}
export async function getSchema(): Promise<SchemaResponse> {
return fetchApi<SchemaResponse>('/schema');
}
// =============================================================================
// Data Completeness Configuration
// =============================================================================
export interface CompletenessFieldConfig {
id: string;
name: string;
fieldPath: string;
enabled: boolean;
}
export interface CompletenessCategoryConfig {
id: string;
name: string;
description: string;
fields: CompletenessFieldConfig[];
}
export interface DataCompletenessConfig {
metadata: {
version: string;
description: string;
lastUpdated: string;
};
categories: CompletenessCategoryConfig[]; // Array of categories (dynamic)
}
export async function getDataCompletenessConfig(): Promise<DataCompletenessConfig> {
return fetchApi<DataCompletenessConfig>('/configuration/data-completeness');
}
export async function updateDataCompletenessConfig(config: DataCompletenessConfig): Promise<{ success: boolean; message: string }> {
return fetchApi<{ success: boolean; message: string }>('/configuration/data-completeness', {
method: 'PUT',
body: JSON.stringify(config),
});
}
// =============================================================================
// BIA Comparison
// =============================================================================
export async function getBIAComparison(): Promise<BIAComparisonResponse> {
return fetchApi<BIAComparisonResponse>('/applications/bia-comparison');
}
// =============================================================================
// Business Importance vs BIA Comparison
// =============================================================================
export async function getBusinessImportanceComparison(): Promise<BusinessImportanceComparisonResponse> {
return fetchApi<BusinessImportanceComparisonResponse>('/applications/business-importance-comparison');
}

View File

@@ -47,8 +47,10 @@ export interface ApplicationListItem {
minFTE?: number | null; // Minimum FTE from configuration range
maxFTE?: number | null; // Maximum FTE from configuration range
overrideFTE?: number | null; // Override FTE value (if set, overrides calculated value)
businessImpactAnalyse?: ReferenceValue | null; // Business Impact Analyse
applicationManagementHosting?: ReferenceValue | null; // Application Management - Hosting
applicationManagementTAM?: ReferenceValue | null; // Application Management - TAM
dataCompletenessPercentage?: number; // Data completeness percentage (0-100)
}
// Full application details
@@ -85,6 +87,7 @@ export interface ApplicationDetails {
applicationManagementHosting?: ReferenceValue | null; // Application Management - Hosting
applicationManagementTAM?: ReferenceValue | null; // Application Management - TAM
technischeArchitectuur?: string | null; // URL to Technical Architecture document (Attribute ID 572)
dataCompletenessPercentage?: number; // Data completeness percentage (0-100)
}
// Search filters
@@ -355,3 +358,61 @@ export interface ChatResponse {
message: ChatMessage;
suggestion?: AISuggestion; // Updated suggestion if AI provided one
}
// BIA Comparison types
export interface BIAComparisonItem {
id: string;
key: string;
name: string;
searchReference: string | null;
currentBIA: ReferenceValue | null;
excelBIA: string | null;
excelApplicationName: string | null;
matchStatus: 'match' | 'mismatch' | 'not_found' | 'no_excel_bia';
matchType: 'exact' | 'search_reference' | 'fuzzy' | null;
matchConfidence?: number;
allMatches?: Array<{
excelApplicationName: string;
biaValue: string;
matchType: 'exact' | 'search_reference' | 'partial_starts' | 'partial_contains' | 'fuzzy';
confidence: number;
}>;
}
export interface BIAComparisonResponse {
applications: BIAComparisonItem[];
summary: {
total: number;
matched: number;
mismatched: number;
notFound: number;
noExcelBIA: number;
};
}
// Business Importance vs BIA Comparison types
export interface BusinessImportanceComparisonItem {
id: string;
key: string;
name: string;
searchReference: string | null;
businessImportance: string | null;
businessImportanceNormalized: number | null; // 0-6 scale
businessImpactAnalyse: ReferenceValue | null;
biaClass: string | null; // A-F
biaClassNormalized: number | null; // 1-6 scale (A=1, F=6)
discrepancyScore: number; // Absolute difference
discrepancyCategory: 'high_bi_low_bia' | 'low_bi_high_bia' | 'aligned' | 'missing_data';
}
export interface BusinessImportanceComparisonResponse {
applications: BusinessImportanceComparisonItem[];
summary: {
total: number;
withBothFields: number;
highBiLowBia: number; // High Business Importance, Low BIA
lowBiHighBia: number; // Low Business Importance, High BIA
aligned: number;
missingData: number;
};
}