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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
615
frontend/src/components/BIASyncDashboard.tsx
Normal file
615
frontend/src/components/BIASyncDashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
347
frontend/src/components/BusinessImportanceComparison.tsx
Normal file
347
frontend/src/components/BusinessImportanceComparison.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
360
frontend/src/components/ComplexityDynamicsBubbleChart.tsx
Normal file
360
frontend/src/components/ComplexityDynamicsBubbleChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
821
frontend/src/components/DataCompletenessConfig.tsx
Normal file
821
frontend/src/components/DataCompletenessConfig.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
495
frontend/src/components/DataCompletenessScore.tsx
Normal file
495
frontend/src/components/DataCompletenessScore.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
406
frontend/src/components/FTEPerZiRADomain.tsx
Normal file
406
frontend/src/components/FTEPerZiRADomain.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
171
frontend/src/components/GovernanceModelBadge.tsx
Normal file
171
frontend/src/components/GovernanceModelBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
336
frontend/src/components/LifecyclePipeline.tsx
Normal file
336
frontend/src/components/LifecyclePipeline.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
314
frontend/src/components/TeamPortfolioHealth.tsx
Normal file
314
frontend/src/components/TeamPortfolioHealth.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
469
frontend/src/components/TechnicalDebtHeatmap.tsx
Normal file
469
frontend/src/components/TechnicalDebtHeatmap.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
378
frontend/src/components/ZiRADomainCoverage.tsx
Normal file
378
frontend/src/components/ZiRADomainCoverage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user