Improve Team-indeling dashboard UI and cache invalidation

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

View File

@@ -1,14 +1,112 @@
import { useEffect, useState } from 'react';
import { Routes, Route, Link, useLocation } from 'react-router-dom';
import { useEffect, useState, useRef } from 'react';
import { Routes, Route, Link, useLocation, Navigate, useParams } from 'react-router-dom';
import { clsx } from 'clsx';
import SearchDashboard from './components/SearchDashboard';
import Dashboard from './components/Dashboard';
import ApplicationList from './components/ApplicationList';
import ApplicationDetail from './components/ApplicationDetail';
import ApplicationInfo from './components/ApplicationInfo';
import GovernanceModelHelper from './components/GovernanceModelHelper';
import TeamDashboard from './components/TeamDashboard';
import ConfigurationV25 from './components/ConfigurationV25';
import ReportsDashboard from './components/ReportsDashboard';
import GovernanceAnalysis from './components/GovernanceAnalysis';
import DataModelDashboard from './components/DataModelDashboard';
import FTECalculator from './components/FTECalculator';
import Login from './components/Login';
import { useAuthStore } from './stores/authStore';
// Redirect component for old app-components/overview/:id paths
function RedirectToApplicationEdit() {
const { id } = useParams<{ id: string }>();
return <Navigate to={`/application/${id}/edit`} replace />;
}
// Dropdown menu item type
interface NavItem {
path: string;
label: string;
exact?: boolean;
}
interface NavDropdown {
label: string;
icon?: React.ReactNode;
items: NavItem[];
basePath: string;
}
// Dropdown component for navigation
function NavDropdown({ dropdown, isActive }: { dropdown: NavDropdown; isActive: boolean }) {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const location = useLocation();
// Close dropdown when clicking outside
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// Close dropdown on route change
useEffect(() => {
setIsOpen(false);
}, [location.pathname]);
return (
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setIsOpen(!isOpen)}
className={clsx(
'flex items-center gap-1 px-3 py-2 rounded-md text-sm font-medium transition-colors',
isActive
? 'bg-blue-50 text-blue-700'
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'
)}
>
{dropdown.label}
<svg
className={clsx('w-4 h-4 transition-transform', isOpen && '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>
{isOpen && (
<div className="absolute left-0 mt-1 w-48 bg-white rounded-lg shadow-lg border border-gray-200 py-1 z-50">
{dropdown.items.map((item) => {
const itemActive = item.exact
? location.pathname === item.path
: location.pathname.startsWith(item.path);
return (
<Link
key={item.path}
to={item.path}
className={clsx(
'block px-4 py-2 text-sm transition-colors',
itemActive
? 'bg-blue-50 text-blue-700'
: 'text-gray-700 hover:bg-gray-50'
)}
>
{item.label}
</Link>
);
})}
</div>
)}
</div>
);
}
function UserMenu() {
const { user, authMethod, logout } = useAuthStore();
const [isOpen, setIsOpen] = useState(false);
@@ -84,12 +182,32 @@ function UserMenu() {
function AppContent() {
const location = useLocation();
const navItems = [
{ path: '/', label: 'Dashboard', exact: true },
{ path: '/applications', label: 'Applicaties', exact: false },
{ path: '/teams', label: 'Team-indeling', exact: true },
{ path: '/configuration', label: 'FTE Config v25', exact: true },
];
// Navigation structure
const appComponentsDropdown: NavDropdown = {
label: 'Application Component',
basePath: '/application',
items: [
{ 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 },
],
};
const reportsDropdown: NavDropdown = {
label: 'Rapporten',
basePath: '/reports',
items: [
{ 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 },
],
};
const isAppComponentsActive = location.pathname.startsWith('/app-components') || location.pathname.startsWith('/application');
const isReportsActive = location.pathname.startsWith('/reports');
const isDashboardActive = location.pathname === '/';
return (
<div className="min-h-screen bg-white">
@@ -98,39 +216,35 @@ function AppContent() {
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
<div className="flex items-center space-x-8">
<div className="flex items-center space-x-3">
<div className="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center">
<span className="text-white font-bold text-sm">ZiRA</span>
</div>
<Link to="/" className="flex items-center space-x-3">
<img src="/logo-zuyderland.svg" alt="Zuyderland" className="w-9 h-9" />
<div>
<h1 className="text-lg font-semibold text-gray-900">
Classificatie Tool
Analyse Tool
</h1>
<p className="text-xs text-gray-500">Zuyderland CMDB</p>
</div>
</div>
</Link>
<nav className="hidden md:flex space-x-1">
{navItems.map((item) => {
const isActive = item.exact
? location.pathname === item.path
: location.pathname.startsWith(item.path);
<nav className="hidden md:flex items-center space-x-1">
{/* Dashboard (Search) */}
<Link
to="/"
className={clsx(
'px-3 py-2 rounded-md text-sm font-medium transition-colors',
isDashboardActive
? 'bg-blue-50 text-blue-700'
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'
)}
>
Dashboard
</Link>
return (
<Link
key={item.path}
to={item.path}
className={clsx(
'px-3 py-2 rounded-md text-sm font-medium transition-colors',
isActive
? 'bg-blue-50 text-blue-700'
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'
)}
>
{item.label}
</Link>
);
})}
{/* Application Component Dropdown */}
<NavDropdown dropdown={appComponentsDropdown} isActive={isAppComponentsActive} />
{/* Reports Dropdown */}
<NavDropdown dropdown={reportsDropdown} isActive={isReportsActive} />
</nav>
</div>
@@ -142,9 +256,30 @@ function AppContent() {
{/* Main content */}
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/applications" element={<ApplicationList />} />
<Route path="/applications/:id" element={<ApplicationDetail />} />
{/* Main Dashboard (Search) */}
<Route path="/" element={<SearchDashboard />} />
{/* Application routes (new structure) */}
<Route path="/application/overview" element={<ApplicationList />} />
<Route path="/application/fte-calculator" element={<FTECalculator />} />
<Route path="/application/:id" element={<ApplicationInfo />} />
<Route path="/application/:id/edit" element={<GovernanceModelHelper />} />
{/* 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 />} />
{/* 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="/applications" element={<Navigate to="/application/overview" replace />} />
<Route path="/applications/:id" element={<RedirectToApplicationEdit />} />
<Route path="/teams" element={<TeamDashboard />} />
<Route path="/configuration" element={<ConfigurationV25 />} />
</Routes>
@@ -178,12 +313,12 @@ function App() {
}
// Show login if OAuth is enabled and not authenticated
if (config?.oauthEnabled && !isAuthenticated) {
if (config?.authMethod === 'oauth' && !isAuthenticated) {
return <Login />;
}
// Show login if nothing is configured
if (!config?.oauthEnabled && !config?.serviceAccountEnabled) {
if (config?.authMethod === 'none') {
return <Login />;
}

View File

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

View File

@@ -29,7 +29,7 @@ export default function ApplicationList() {
setStatuses,
setApplicationFunction,
setGovernanceModel,
setApplicationCluster,
setApplicationSubteam,
setApplicationType,
setOrganisation,
setHostingType,
@@ -45,6 +45,7 @@ export default function ApplicationList() {
const [organisations, setOrganisations] = useState<ReferenceValue[]>([]);
const [hostingTypes, setHostingTypes] = useState<ReferenceValue[]>([]);
const [businessImportanceOptions, setBusinessImportanceOptions] = useState<ReferenceValue[]>([]);
const [applicationSubteams, setApplicationSubteams] = useState<ReferenceValue[]>([]);
const [showFilters, setShowFilters] = useState(true);
// Sync URL params with store on mount
@@ -98,6 +99,7 @@ export default function ApplicationList() {
setOrganisations(data.organisations);
setHostingTypes(data.hostingTypes);
setBusinessImportanceOptions(data.businessImportance || []);
setApplicationSubteams(data.applicationSubteams || []);
} catch (err) {
console.error('Failed to load reference data', err);
}
@@ -126,7 +128,7 @@ export default function ApplicationList() {
// Only navigate programmatically for regular clicks
if (!event.ctrlKey && !event.metaKey && !event.shiftKey && event.button === 0) {
event.preventDefault();
navigate(`/applications/${app.id}`);
navigate(`/application/${app.id}`);
}
};
@@ -257,26 +259,6 @@ export default function ApplicationList() {
</div>
</div>
<div>
<label className="label mb-2">Application Cluster</label>
<div className="space-y-1">
{(['all', 'filled', 'empty'] as const).map((value) => (
<label key={value} className="flex items-center space-x-2">
<input
type="radio"
name="applicationCluster"
checked={filters.applicationCluster === value}
onChange={() => setApplicationCluster(value)}
className="border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700">
{value === 'all' ? 'Alle' : value === 'filled' ? 'Ingevuld' : 'Leeg'}
</span>
</label>
))}
</div>
</div>
<div>
<label className="label mb-2">Application Type</label>
<div className="space-y-1">
@@ -347,6 +329,23 @@ export default function ApplicationList() {
))}
</select>
</div>
<div>
<label className="label mb-2">Subteam</label>
<select
value={filters.applicationSubteam || 'all'}
onChange={(e) => setApplicationSubteam(e.target.value as 'all' | 'empty' | string)}
className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="all">Alle</option>
<option value="empty">Leeg</option>
{applicationSubteams.map((subteam) => (
<option key={subteam.objectId} value={subteam.name}>
{subteam.name}
</option>
))}
</select>
</div>
</div>
</div>
</div>
@@ -405,7 +404,7 @@ export default function ApplicationList() {
>
<td className="py-0">
<Link
to={`/applications/${app.id}`}
to={`/application/${app.id}`}
onClick={(e) => handleRowClick(app, index, e)}
className="block px-4 py-3 text-sm text-gray-500"
>
@@ -414,7 +413,7 @@ export default function ApplicationList() {
</td>
<td className="py-0">
<Link
to={`/applications/${app.id}`}
to={`/application/${app.id}`}
onClick={(e) => handleRowClick(app, index, e)}
className="block px-4 py-3"
>
@@ -426,7 +425,7 @@ export default function ApplicationList() {
</td>
<td className="py-0">
<Link
to={`/applications/${app.id}`}
to={`/application/${app.id}`}
onClick={(e) => handleRowClick(app, index, e)}
className="block px-4 py-3"
>
@@ -435,7 +434,7 @@ export default function ApplicationList() {
</td>
<td className="py-0">
<Link
to={`/applications/${app.id}`}
to={`/application/${app.id}`}
onClick={(e) => handleRowClick(app, index, e)}
className="block px-4 py-3"
>
@@ -460,7 +459,7 @@ export default function ApplicationList() {
</td>
<td className="py-0">
<Link
to={`/applications/${app.id}`}
to={`/application/${app.id}`}
onClick={(e) => handleRowClick(app, index, e)}
className="block px-4 py-3"
>
@@ -477,7 +476,7 @@ export default function ApplicationList() {
</td>
<td className="py-0">
<Link
to={`/applications/${app.id}`}
to={`/application/${app.id}`}
onClick={(e) => handleRowClick(app, index, e)}
className="block px-4 py-3 text-sm text-gray-900"
>
@@ -502,7 +501,7 @@ export default function ApplicationList() {
<div className="px-4 py-3 bg-gray-50 border-t border-gray-200 flex items-center justify-between">
{currentPage > 1 ? (
<Link
to={currentPage === 2 ? '/applications' : `/applications?page=${currentPage - 1}`}
to={currentPage === 2 ? '/application/overview' : `/application/overview?page=${currentPage - 1}`}
onClick={() => setCurrentPage(currentPage - 1)}
className="btn btn-secondary"
>
@@ -518,7 +517,7 @@ export default function ApplicationList() {
</span>
{currentPage < result.totalPages ? (
<Link
to={`/applications?page=${currentPage + 1}`}
to={`/application/overview?page=${currentPage + 1}`}
onClick={() => setCurrentPage(currentPage + 1)}
className="btn btn-secondary"
>

View File

@@ -0,0 +1,180 @@
import React, { useState, useEffect } from 'react';
import { getCacheStatus, triggerSync, type CacheStatus } from '../services/api';
interface CacheStatusIndicatorProps {
/** Show compact version (just icon + time) */
compact?: boolean;
/** Auto-refresh interval in ms (default: 30000) */
refreshInterval?: number;
}
export const CacheStatusIndicator: React.FC<CacheStatusIndicatorProps> = ({
compact = false,
refreshInterval = 30000,
}) => {
const [status, setStatus] = useState<CacheStatus | null>(null);
const [loading, setLoading] = useState(true);
const [syncing, setSyncing] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchStatus = async () => {
try {
const data = await getCacheStatus();
setStatus(data);
setError(null);
} catch (err) {
setError('Kon status niet ophalen');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchStatus();
const interval = setInterval(fetchStatus, refreshInterval);
return () => clearInterval(interval);
}, [refreshInterval]);
const handleSync = async () => {
if (syncing) return;
setSyncing(true);
try {
await triggerSync();
// Refetch status after a short delay
setTimeout(fetchStatus, 1000);
} catch (err) {
setError('Sync mislukt');
} finally {
setSyncing(false);
}
};
if (loading) {
return (
<div className={`inline-flex items-center gap-2 ${compact ? 'text-xs' : 'text-sm'} text-zinc-500`}>
<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>
{!compact && <span>Laden...</span>}
</div>
);
}
if (error || !status) {
return (
<div className={`inline-flex items-center gap-2 ${compact ? 'text-xs' : 'text-sm'} text-red-400`}>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{!compact && <span>{error || 'Geen data'}</span>}
</div>
);
}
const lastSync = status.cache.lastIncrementalSync;
const ageMinutes = lastSync
? Math.floor((Date.now() - new Date(lastSync).getTime()) / 60000)
: null;
const isWarm = status.cache.isWarm;
const isSyncing = status.sync.isSyncing || syncing;
// Status color
const statusColor = !isWarm
? 'text-amber-400'
: ageMinutes !== null && ageMinutes > 5
? 'text-amber-400'
: 'text-emerald-400';
if (compact) {
return (
<button
onClick={handleSync}
disabled={isSyncing}
className={`inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs ${statusColor} hover:bg-zinc-800 transition-colors disabled:opacity-50`}
title={`Cache: ${status.cache.totalObjects} objecten, laatst gesynchroniseerd ${ageMinutes !== null ? `${ageMinutes} min geleden` : 'onbekend'}`}
>
{isSyncing ? (
<svg className="animate-spin h-3.5 w-3.5" 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>
) : (
<svg className="h-3.5 w-3.5" 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>{ageMinutes !== null ? `${ageMinutes}m` : '?'}</span>
</button>
);
}
return (
<div className="bg-zinc-800/50 rounded-lg border border-zinc-700 p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium text-zinc-300 flex items-center gap-2">
<svg className="h-4 w-4 text-zinc-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4" />
</svg>
Cache Status
</h3>
<span className={`text-xs px-2 py-0.5 rounded-full ${isWarm ? 'bg-emerald-500/20 text-emerald-400' : 'bg-amber-500/20 text-amber-400'}`}>
{isWarm ? 'Actief' : 'Cold Start'}
</span>
</div>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-zinc-500">Objecten in cache:</span>
<span className="text-zinc-200 font-mono">{status.cache.totalObjects.toLocaleString()}</span>
</div>
<div className="flex justify-between">
<span className="text-zinc-500">Relaties:</span>
<span className="text-zinc-200 font-mono">{status.cache.totalRelations.toLocaleString()}</span>
</div>
<div className="flex justify-between">
<span className="text-zinc-500">Laatst gesynchroniseerd:</span>
<span className={`font-mono ${statusColor}`}>
{ageMinutes !== null ? `${ageMinutes} min geleden` : 'Nooit'}
</span>
</div>
{status.sync.isSyncing && (
<div className="flex items-center gap-2 text-blue-400">
<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>Synchronisatie bezig...</span>
</div>
)}
</div>
<button
onClick={handleSync}
disabled={isSyncing}
className="mt-4 w-full flex items-center justify-center gap-2 px-3 py-2 rounded-lg bg-zinc-700 text-zinc-200 hover:bg-zinc-600 transition-colors disabled:opacity-50 text-sm font-medium"
>
{isSyncing ? (
<>
<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>
Synchroniseren...
</>
) : (
<>
<svg className="h-4 w-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>
Nu synchroniseren
</>
)}
</button>
</div>
);
};
export default CacheStatusIndicator;

View File

@@ -0,0 +1,162 @@
import React from 'react';
import type { ConflictError } from '../services/api';
interface ConflictDialogProps {
conflict: ConflictError;
onForceOverwrite: () => void;
onDiscard: () => void;
onClose: () => void;
isLoading?: boolean;
}
export const ConflictDialog: React.FC<ConflictDialogProps> = ({
conflict,
onForceOverwrite,
onDiscard,
onClose,
isLoading = false,
}) => {
return (
<div className="fixed inset-0 z-50 overflow-y-auto">
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/60 transition-opacity"
onClick={onClose}
/>
{/* Dialog */}
<div className="flex min-h-full items-center justify-center p-4">
<div className="relative bg-zinc-900 rounded-xl shadow-2xl border border-zinc-700 max-w-2xl w-full overflow-hidden">
{/* Header */}
<div className="px-6 py-4 border-b border-zinc-700 bg-amber-950/50">
<div className="flex items-center gap-3">
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-amber-500/20 flex items-center justify-center">
<svg className="w-5 h-5 text-amber-400" 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>
</div>
<div>
<h2 className="text-lg font-semibold text-amber-400">
Wijzigingsconflict Gedetecteerd
</h2>
<p className="text-sm text-zinc-400">
{conflict.message}
</p>
</div>
</div>
</div>
{/* Content */}
<div className="px-6 py-5">
{conflict.warning && (
<p className="text-sm text-zinc-400 mb-4">{conflict.warning}</p>
)}
{conflict.conflicts && conflict.conflicts.length > 0 && (
<>
<p className="text-sm text-zinc-300 mb-4">
De volgende velden zijn gewijzigd terwijl u aan het bewerken was:
</p>
<div className="rounded-lg overflow-hidden border border-zinc-700">
<table className="w-full text-sm">
<thead>
<tr className="bg-zinc-800">
<th className="px-4 py-2.5 text-left font-medium text-zinc-300">Veld</th>
<th className="px-4 py-2.5 text-left font-medium text-zinc-300">Uw waarde</th>
<th className="px-4 py-2.5 text-left font-medium text-zinc-300">Waarde in Jira</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-700">
{conflict.conflicts.map((c, index) => (
<tr key={index} className="bg-zinc-800/50">
<td className="px-4 py-3 font-medium text-zinc-200">
{c.field}
</td>
<td className="px-4 py-3">
<span className="inline-flex items-center px-2 py-1 rounded bg-blue-500/20 text-blue-400 text-xs font-mono">
{formatValue(c.proposedValue)}
</span>
</td>
<td className="px-4 py-3">
<span className="inline-flex items-center px-2 py-1 rounded bg-amber-500/20 text-amber-400 text-xs font-mono">
{formatValue(c.jiraValue)}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
{/* Info box */}
<div className="mt-5 p-4 rounded-lg bg-zinc-800/50 border border-zinc-700">
<p className="text-sm text-zinc-400">
<span className="font-medium text-zinc-300">Wat wilt u doen?</span>
<br />
<strong>Doorvoeren:</strong> Uw wijzigingen overschrijven de huidige waarden in Jira
<br />
<strong>Verwerpen:</strong> Uw wijzigingen worden weggegooid en de huidige data wordt geladen
</p>
</div>
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-zinc-700 bg-zinc-800/50 flex justify-end gap-3">
<button
onClick={onDiscard}
disabled={isLoading}
className="px-4 py-2 rounded-lg bg-zinc-700 text-zinc-200 hover:bg-zinc-600 transition-colors disabled:opacity-50 font-medium"
>
Verwerpen en verversen
</button>
<button
onClick={onForceOverwrite}
disabled={isLoading}
className="px-4 py-2 rounded-lg bg-amber-600 text-white hover:bg-amber-500 transition-colors disabled:opacity-50 font-medium flex items-center gap-2"
>
{isLoading && (
<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>
)}
Mijn wijzigingen doorvoeren
</button>
</div>
</div>
</div>
</div>
);
};
function formatValue(value: unknown): string {
if (value === null || value === undefined) {
return '(leeg)';
}
if (Array.isArray(value)) {
if (value.length === 0) return '(leeg)';
return value.map(v => {
if (typeof v === 'object' && v && 'label' in v) {
return (v as { label: string }).label;
}
return String(v);
}).join(', ');
}
if (typeof value === 'object' && value && 'label' in value) {
return (value as { label: string }).label;
}
if (typeof value === 'boolean') {
return value ? 'Ja' : 'Nee';
}
return String(value);
}
export default ConflictDialog;

View File

@@ -14,19 +14,32 @@ interface CustomSelectProps {
// Helper function to get display text for an option
function getDisplayText(option: ReferenceValue, showSummary: boolean, showRemarks: boolean): string | null {
if (showRemarks) {
// Concatenate description and remarks with ". "
// Concatenate description, remarks, and indicators with ". "
const parts: string[] = [];
if (option.description) parts.push(option.description);
if (option.remarks) parts.push(option.remarks);
if (option.indicators) parts.push(option.indicators);
return parts.length > 0 ? parts.join('. ') : null;
}
if (showSummary && option.summary) {
// Include indicators if available
if (option.indicators) {
return `${option.summary}. ${option.indicators}`;
}
return option.summary;
}
if (showSummary && !option.summary && option.description) {
// Include indicators if available
if (option.indicators) {
return `${option.description}. ${option.indicators}`;
}
return option.description;
}
if (!showSummary && option.description) {
// Include indicators if available
if (option.indicators) {
return `${option.description}. ${option.indicators}`;
}
return option.description;
}
return null;

View File

@@ -1,7 +1,7 @@
import { useEffect, useState, useCallback } from 'react';
import { useEffect, useState, useCallback, useRef } from 'react';
import { Link } from 'react-router-dom';
import { getDashboardStats, getRecentClassifications } from '../services/api';
import type { DashboardStats, ClassificationResult } from '../types';
import { getDashboardStats, getRecentClassifications, getReferenceData } from '../services/api';
import type { DashboardStats, ClassificationResult, ReferenceValue } from '../types';
// Extended type to include stale indicator from API
interface DashboardStatsWithMeta extends DashboardStats {
@@ -12,9 +12,36 @@ interface DashboardStatsWithMeta extends DashboardStats {
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) {
@@ -25,12 +52,14 @@ export default function Dashboard() {
setError(null);
try {
const [statsData, recentData] = await Promise.all([
const [statsData, recentData, refData] = await Promise.all([
getDashboardStats(forceRefresh),
getRecentClassifications(10),
getReferenceData(),
]);
setStats(statsData as DashboardStatsWithMeta);
setRecentClassifications(recentData);
setGovernanceModels(refData.governanceModels);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load dashboard');
} finally {
@@ -104,7 +133,7 @@ export default function Dashboard() {
</svg>
<span>{refreshing ? 'Laden...' : 'Ververs'}</span>
</button>
<Link to="/applications" className="btn btn-primary">
<Link to="/app-components/overview" className="btn btn-primary">
Start classificeren
</Link>
</div>
@@ -141,23 +170,42 @@ export default function Dashboard() {
</div>
</div>
{/* Progress bar */}
{/* Progress bars */}
<div className="card p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">
Classificatie voortgang
</h3>
<div className="space-y-2">
<div className="flex justify-between text-sm text-gray-600">
<span>ApplicationFunction ingevuld</span>
<span>
{stats?.classifiedCount || 0} / {stats?.totalApplications || 0}
</span>
<div className="space-y-4">
{/* ICT Governance Model Progress */}
<div className="space-y-2">
<div className="flex justify-between text-sm text-gray-600">
<span>ICT Governance Model ingevuld</span>
<span>
{stats?.classifiedCount || 0} / {stats?.totalApplications || 0} ({progressPercentage}%)
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-4">
<div
className="bg-blue-600 h-4 rounded-full transition-all duration-500"
style={{ width: `${progressPercentage}%` }}
/>
</div>
</div>
<div className="w-full bg-gray-200 rounded-full h-4">
<div
className="bg-blue-600 h-4 rounded-full transition-all duration-500"
style={{ width: `${progressPercentage}%` }}
/>
{/* ApplicationFunction Progress */}
<div className="space-y-2">
<div className="flex justify-between text-sm text-gray-600">
<span>ApplicationFunction ingevuld</span>
<span>
{stats?.withApplicationFunction || 0} / {stats?.totalApplications || 0} ({stats?.applicationFunctionPercentage || 0}%)
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-4">
<div
className="bg-green-600 h-4 rounded-full transition-all duration-500"
style={{ width: `${stats?.applicationFunctionPercentage || 0}%` }}
/>
</div>
</div>
</div>
</div>
@@ -186,7 +234,7 @@ export default function Dashboard() {
<div
className="bg-blue-600 h-2 rounded-full"
style={{
width: `${(count / (stats?.totalApplications || 1)) * 100}%`,
width: `${(count / (stats?.totalAllApplications || 1)) * 100}%`,
}}
/>
</div>
@@ -200,37 +248,110 @@ export default function Dashboard() {
</div>
{/* Governance model distribution */}
<div className="card p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">
<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">
Verdeling per regiemodel
<span className="text-gray-400 text-xs font-normal" title="Hover voor details"></span>
</h3>
<div className="space-y-3">
<div className="flex flex-wrap gap-2" style={{ overflow: 'visible' }}>
{stats?.byGovernanceModel &&
Object.entries(stats.byGovernanceModel)
.sort((a, b) => {
// Sort alphabetically, but put "Niet ingesteld" at the end
if (a[0] === 'Niet ingesteld') return 1;
if (b[0] === 'Niet ingesteld') return -1;
return a[0].localeCompare(b[0], 'nl', { sensitivity: 'base' });
})
.map(([model, count]) => (
<div key={model} className="flex items-center justify-between">
<span className="text-sm text-gray-600">{model}</span>
<div className="flex items-center space-x-2">
<div className="w-32 bg-gray-200 rounded-full h-2">
<div
className="bg-purple-600 h-2 rounded-full"
style={{
width: `${(count / (stats?.totalApplications || 1)) * 100}%`,
}}
/>
[
...governanceModels
.map(g => g.name)
.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>
<span className="text-sm font-medium text-gray-900 w-8 text-right">
<div className="text-xl font-bold leading-tight">
{count}
</span>
</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))' }}
/>
{/* Header: Summary (Description) */}
<div className="text-sm font-bold text-gray-900 mb-2">
{govModelData?.summary || govModel}
{govModelData?.description && (
<span className="font-normal text-gray-500"> ({govModelData.description})</span>
)}
</div>
{/* Remarks */}
{govModelData?.remarks && (
<div className="text-xs text-gray-600 mb-3 whitespace-pre-wrap leading-relaxed">
{govModelData.remarks}
</div>
)}
{/* Application section */}
{govModelData?.application && (
<div className="border-t border-gray-100 pt-3 mt-3">
<div className="text-xs font-bold text-gray-700 mb-1.5 uppercase tracking-wide">
Toepassing
</div>
<div className="text-xs text-gray-600 whitespace-pre-wrap leading-relaxed">
{govModelData.application}
</div>
</div>
)}
{/* Fallback message if no data */}
{!govModelData && (
<div className="text-xs text-gray-400 italic">
Geen aanvullende informatie beschikbaar
</div>
)}
</div>
)}
</div>
</div>
))}
);
})}
{(!stats?.byGovernanceModel ||
Object.keys(stats.byGovernanceModel).length === 0) && (
<p className="text-sm text-gray-500">Geen data beschikbaar</p>

View File

@@ -0,0 +1,648 @@
import { useState, useEffect, useMemo, useCallback } from 'react';
import { getSchema, triggerTypeSync, type SchemaResponse, type SchemaObjectTypeDefinition, type SchemaAttributeDefinition } from '../services/api';
// Attribute type badge colors
const typeColors: Record<string, { bg: string; text: string }> = {
text: { bg: 'bg-gray-100', text: 'text-gray-700' },
integer: { bg: 'bg-blue-100', text: 'text-blue-700' },
float: { bg: 'bg-blue-100', text: 'text-blue-700' },
boolean: { bg: 'bg-purple-100', text: 'text-purple-700' },
date: { bg: 'bg-yellow-100', text: 'text-yellow-700' },
datetime: { bg: 'bg-yellow-100', text: 'text-yellow-700' },
select: { bg: 'bg-green-100', text: 'text-green-700' },
reference: { bg: 'bg-orange-100', text: 'text-orange-700' },
url: { bg: 'bg-cyan-100', text: 'text-cyan-700' },
email: { bg: 'bg-cyan-100', text: 'text-cyan-700' },
textarea: { bg: 'bg-gray-100', text: 'text-gray-700' },
user: { bg: 'bg-pink-100', text: 'text-pink-700' },
status: { bg: 'bg-red-100', text: 'text-red-700' },
unknown: { bg: 'bg-gray-100', text: 'text-gray-500' },
};
function TypeBadge({ type }: { type: string }) {
const colors = typeColors[type] || typeColors.unknown;
return (
<span className={`inline-flex px-2 py-0.5 text-xs font-medium rounded ${colors.bg} ${colors.text}`}>
{type}
</span>
);
}
function AttributeRow({ attr, onReferenceClick }: { attr: SchemaAttributeDefinition; onReferenceClick?: (typeName: string) => void }) {
return (
<tr className="hover:bg-gray-50">
<td className="px-3 py-2 text-sm font-medium text-gray-900">
{attr.name}
{attr.isRequired && <span className="ml-1 text-red-500">*</span>}
</td>
<td className="px-3 py-2 text-sm text-gray-500 font-mono text-xs">
{attr.fieldName}
</td>
<td className="px-3 py-2">
<TypeBadge type={attr.type} />
{attr.isMultiple && (
<span className="ml-1 text-xs text-gray-400">[]</span>
)}
</td>
<td className="px-3 py-2 text-sm">
{attr.referenceTypeName ? (
<button
onClick={() => onReferenceClick?.(attr.referenceTypeName!)}
className="text-blue-600 hover:text-blue-800 hover:underline flex items-center gap-1"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
{attr.referenceTypeName}
</button>
) : (
<span className="text-gray-400"></span>
)}
</td>
<td className="px-3 py-2 text-xs text-gray-500 max-w-xs truncate" title={attr.description}>
{attr.description || '—'}
</td>
<td className="px-3 py-2 text-center">
<div className="flex gap-1 justify-center">
{attr.isSystem && (
<span className="px-1.5 py-0.5 text-xs bg-gray-200 text-gray-600 rounded" title="System attribute">
SYS
</span>
)}
{!attr.isEditable && !attr.isSystem && (
<span className="px-1.5 py-0.5 text-xs bg-yellow-100 text-yellow-700 rounded" title="Read-only">
RO
</span>
)}
</div>
</td>
</tr>
);
}
function ObjectTypeCard({
objectType,
isExpanded,
onToggle,
onReferenceClick,
onRefresh,
isRefreshing,
refreshedCount,
refreshError,
}: {
objectType: SchemaObjectTypeDefinition;
isExpanded: boolean;
onToggle: () => void;
onReferenceClick: (typeName: string) => void;
onRefresh: (typeName: string) => void;
isRefreshing: boolean;
refreshedCount?: number;
refreshError?: string;
}) {
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;
return (
<div className="bg-white rounded-lg border border-gray-200 shadow-sm overflow-hidden">
{/* Header */}
<div
className="px-4 py-3 bg-gradient-to-r from-blue-50 to-indigo-50 border-b border-gray-200 cursor-pointer hover:from-blue-100 hover:to-indigo-100 transition-colors"
onClick={onToggle}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex-shrink-0 w-10 h-10 bg-blue-600 rounded-lg flex items-center justify-center">
<svg className="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4" />
</svg>
</div>
<div>
<h3 className="font-semibold text-gray-900">{objectType.name}</h3>
<p className="text-xs text-gray-500 font-mono">{objectType.typeName}</p>
</div>
</div>
<div className="flex items-center gap-4">
<div className="text-right">
<div className="text-sm font-medium text-gray-700">
{displayCount.toLocaleString()} objects
</div>
<div className="text-xs text-gray-500">
{objectType.attributes.length} attributes
</div>
</div>
<div className="flex gap-2">
{objectType.incomingLinks.length > 0 && (
<span className="px-2 py-1 text-xs bg-green-100 text-green-700 rounded-full" title="Incoming references">
{objectType.incomingLinks.length}
</span>
)}
{objectType.outgoingLinks.length > 0 && (
<span className="px-2 py-1 text-xs bg-orange-100 text-orange-700 rounded-full" title="Outgoing references">
{objectType.outgoingLinks.length}
</span>
)}
</div>
{/* Refresh button */}
<button
onClick={(e) => {
e.stopPropagation();
onRefresh(objectType.typeName);
}}
disabled={isRefreshing}
className={`p-2 rounded-lg transition-colors ${
isRefreshing
? 'bg-blue-100 text-blue-400 cursor-not-allowed'
: 'hover:bg-blue-100 text-blue-600 hover:text-blue-700'
}`}
title={isRefreshing ? 'Bezig met verversen...' : 'Ververs alle objecten van dit type'}
>
<svg
className={`w-4 h-4 ${isRefreshing ? 'animate-spin' : ''}`}
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>
</button>
<svg
className={`w-5 h-5 text-gray-400 transition-transform ${isExpanded ? '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>
</div>
</div>
</div>
{/* Error message */}
{refreshError && (
<div className="px-4 py-2 bg-red-50 border-b border-red-200">
<div className="flex items-center gap-2 text-sm text-red-700">
<svg className="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>{refreshError}</span>
</div>
</div>
)}
{/* Expanded Content */}
{isExpanded && (
<div className="divide-y divide-gray-100">
{/* Links Section */}
{(objectType.incomingLinks.length > 0 || objectType.outgoingLinks.length > 0) && (
<div className="px-4 py-3 bg-gray-50">
<h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">
Relationships
</h4>
<div className="grid grid-cols-2 gap-4">
{/* Incoming Links */}
{objectType.incomingLinks.length > 0 && (
<div>
<div className="text-xs font-medium text-green-700 mb-1 flex items-center gap-1">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 17l-5-5m0 0l5-5m-5 5h12" />
</svg>
Referenced by ({objectType.incomingLinks.length})
</div>
<div className="space-y-1">
{objectType.incomingLinks.map((link, idx) => (
<button
key={idx}
onClick={() => onReferenceClick(link.fromType)}
className="block text-sm text-blue-600 hover:text-blue-800 hover:underline"
>
{link.fromTypeName}
<span className="text-gray-400 text-xs ml-1">({link.attributeName})</span>
{link.isMultiple && <span className="text-gray-400 text-xs ml-0.5">[]</span>}
</button>
))}
</div>
</div>
)}
{/* Outgoing Links */}
{objectType.outgoingLinks.length > 0 && (
<div>
<div className="text-xs font-medium text-orange-700 mb-1 flex items-center gap-1">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
References ({objectType.outgoingLinks.length})
</div>
<div className="space-y-1">
{objectType.outgoingLinks.map((link, idx) => (
<button
key={idx}
onClick={() => onReferenceClick(link.toType)}
className="block text-sm text-blue-600 hover:text-blue-800 hover:underline"
>
{link.toTypeName}
<span className="text-gray-400 text-xs ml-1">({link.attributeName})</span>
{link.isMultiple && <span className="text-gray-400 text-xs ml-0.5">[]</span>}
</button>
))}
</div>
</div>
)}
</div>
</div>
)}
{/* Attributes Table */}
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Name
</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Field
</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Type
</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Reference
</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Description
</th>
<th className="px-3 py-2 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">
Flags
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-100">
{/* Reference attributes first */}
{referenceAttrs.map((attr) => (
<AttributeRow key={attr.jiraId} attr={attr} onReferenceClick={onReferenceClick} />
))}
{/* Then non-reference attributes */}
{nonReferenceAttrs.map((attr) => (
<AttributeRow key={attr.jiraId} attr={attr} onReferenceClick={onReferenceClick} />
))}
</tbody>
</table>
</div>
</div>
)}
</div>
);
}
export default function DataModelDashboard() {
const [schema, setSchema] = useState<SchemaResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [expandedTypes, setExpandedTypes] = useState<Set<string>>(new Set());
const [sortBy, setSortBy] = useState<'name' | 'objects' | 'attributes' | 'priority'>('priority');
const [refreshingTypes, setRefreshingTypes] = useState<Set<string>>(new Set());
const [refreshedCounts, setRefreshedCounts] = useState<Record<string, number>>({});
const [refreshErrors, setRefreshErrors] = useState<Record<string, string>>({});
useEffect(() => {
loadSchema();
}, []);
async function loadSchema() {
try {
setLoading(true);
setError(null);
const data = await getSchema();
setSchema(data);
// Reset refreshed counts when schema is reloaded
setRefreshedCounts({});
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load schema');
} finally {
setLoading(false);
}
}
const handleRefreshType = useCallback(async (typeName: string) => {
// Add to refreshing set and clear any previous error
setRefreshingTypes((prev) => new Set(prev).add(typeName));
setRefreshErrors((prev) => {
const next = { ...prev };
delete next[typeName];
return next;
});
try {
const result = await triggerTypeSync(typeName);
// Update the count for this type
if (result.stats?.objectsProcessed !== undefined) {
setRefreshedCounts((prev) => ({
...prev,
[typeName]: result.stats.objectsProcessed,
}));
}
} catch (err) {
console.error(`Failed to refresh ${typeName}:`, err);
// Extract error message
let errorMessage = 'Failed to sync object type';
if (err instanceof Error) {
errorMessage = err.message;
} else if (typeof err === 'object' && err !== null && 'error' in err) {
errorMessage = String(err.error);
}
setRefreshErrors((prev) => ({
...prev,
[typeName]: errorMessage,
}));
} finally {
// Remove from refreshing set
setRefreshingTypes((prev) => {
const next = new Set(prev);
next.delete(typeName);
return next;
});
}
}, []);
const filteredAndSortedTypes = useMemo(() => {
if (!schema) return [];
let types = Object.values(schema.objectTypes);
// Filter by search query
if (searchQuery) {
const query = searchQuery.toLowerCase();
types = types.filter(
(t) =>
t.name.toLowerCase().includes(query) ||
t.typeName.toLowerCase().includes(query) ||
t.attributes.some(
(a) =>
a.name.toLowerCase().includes(query) ||
a.fieldName.toLowerCase().includes(query)
)
);
}
// Sort
switch (sortBy) {
case 'name':
types.sort((a, b) => a.name.localeCompare(b.name));
break;
case 'objects':
types.sort((a, b) => b.objectCount - a.objectCount);
break;
case 'attributes':
types.sort((a, b) => b.attributes.length - a.attributes.length);
break;
case 'priority':
types.sort((a, b) => a.syncPriority - b.syncPriority || a.name.localeCompare(b.name));
break;
}
return types;
}, [schema, searchQuery, sortBy]);
const toggleExpanded = (typeName: string) => {
setExpandedTypes((prev) => {
const next = new Set(prev);
if (next.has(typeName)) {
next.delete(typeName);
} else {
next.add(typeName);
}
return next;
});
};
const handleReferenceClick = (typeName: string) => {
// Expand the referenced type and scroll to it
setExpandedTypes((prev) => new Set(prev).add(typeName));
// Find and scroll to the element
setTimeout(() => {
const element = document.getElementById(`object-type-${typeName}`);
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}, 100);
};
const expandAll = () => {
setExpandedTypes(new Set(filteredAndSortedTypes.map((t) => t.typeName)));
};
const collapseAll = () => {
setExpandedTypes(new Set());
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-center">
<div className="w-12 h-12 border-4 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
<p className="text-gray-500">Laden van datamodel...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="bg-red-50 border border-red-200 rounded-lg p-6 text-center">
<svg className="w-12 h-12 text-red-400 mx-auto mb-4" 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>
<h3 className="text-lg font-medium text-red-800 mb-2">Fout bij laden</h3>
<p className="text-red-600 mb-4">{error}</p>
<button
onClick={loadSchema}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
>
Opnieuw proberen
</button>
</div>
);
}
if (!schema) return null;
return (
<div>
{/* Header */}
<div className="mb-8">
<h1 className="text-2xl font-bold text-gray-900">Datamodel</h1>
<p className="mt-1 text-gray-500">
Overzicht van alle object types, attributen en relaties in het Jira Assets schema.
</p>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white rounded-lg border border-gray-200 p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4" />
</svg>
</div>
<div>
<div className="text-2xl font-bold text-gray-900">{schema.metadata.objectTypeCount}</div>
<div className="text-sm text-gray-500">Object Types</div>
</div>
</div>
</div>
<div className="bg-white rounded-lg border border-gray-200 p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center">
<svg className="w-5 h-5 text-green-600" 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>
</div>
<div>
<div className="text-2xl font-bold text-gray-900">{schema.metadata.totalAttributes}</div>
<div className="text-sm text-gray-500">Attributen</div>
</div>
</div>
</div>
<div className="bg-white rounded-lg border border-gray-200 p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-orange-100 rounded-lg flex items-center justify-center">
<svg className="w-5 h-5 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
</div>
<div>
<div className="text-2xl font-bold text-gray-900">
{Object.values(schema.objectTypes).reduce((sum, t) => sum + t.outgoingLinks.length, 0)}
</div>
<div className="text-sm text-gray-500">Relaties</div>
</div>
</div>
</div>
<div className="bg-white rounded-lg border border-gray-200 p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center">
<svg className="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div>
<div className="text-sm font-medium text-gray-900">
{new Date(schema.metadata.generatedAt).toLocaleDateString('nl-NL', {
day: 'numeric',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</div>
<div className="text-sm text-gray-500">Gegenereerd</div>
</div>
</div>
</div>
</div>
{/* Toolbar */}
<div className="bg-white rounded-lg border border-gray-200 p-4 mb-6">
<div className="flex flex-wrap items-center gap-4">
{/* Search */}
<div className="flex-1 min-w-[200px]">
<div className="relative">
<svg
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 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>
<input
type="text"
placeholder="Zoek object types of attributen..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
{/* Sort */}
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500">Sorteren op:</span>
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as typeof sortBy)}
className="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="priority">Sync Prioriteit</option>
<option value="name">Naam</option>
<option value="objects">Aantal objecten</option>
<option value="attributes">Aantal attributen</option>
</select>
</div>
{/* Expand/Collapse */}
<div className="flex gap-2">
<button
onClick={expandAll}
className="px-3 py-2 text-sm text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors"
>
Alles uitklappen
</button>
<button
onClick={collapseAll}
className="px-3 py-2 text-sm text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors"
>
Alles inklappen
</button>
</div>
</div>
</div>
{/* Results count */}
<div className="text-sm text-gray-500 mb-4">
{filteredAndSortedTypes.length} object types
{searchQuery && ` gevonden voor "${searchQuery}"`}
</div>
{/* Object Types List */}
<div className="space-y-4">
{filteredAndSortedTypes.map((objectType) => (
<div key={objectType.typeName} id={`object-type-${objectType.typeName}`}>
<ObjectTypeCard
objectType={objectType}
isExpanded={expandedTypes.has(objectType.typeName)}
onToggle={() => toggleExpanded(objectType.typeName)}
onReferenceClick={handleReferenceClick}
onRefresh={handleRefreshType}
isRefreshing={refreshingTypes.has(objectType.typeName)}
refreshedCount={refreshedCounts[objectType.typeName]}
refreshError={refreshErrors[objectType.typeName]}
/>
</div>
))}
</div>
{filteredAndSortedTypes.length === 0 && (
<div className="text-center py-12 text-gray-500">
<svg className="w-12 h-12 mx-auto mb-4 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>Geen object types gevonden voor "{searchQuery}"</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,258 @@
/**
* EffortDisplay - Shared component for displaying FTE effort calculations
*
* Used in:
* - ApplicationInfo.tsx (detail page)
* - GovernanceModelHelper.tsx (governance helper page)
*/
import type { EffortCalculationBreakdown } from '../types';
export interface EffortDisplayProps {
/** The effective FTE value (after override if applicable) */
effectiveFte: number | null;
/** The calculated FTE value (before override) */
calculatedFte?: number | null;
/** Override FTE value if set */
overrideFte?: number | null;
/** Full breakdown from effort calculation */
breakdown?: EffortCalculationBreakdown | null;
/** Whether this is a preview (unsaved changes) */
isPreview?: boolean;
/** Show full details (expanded view) */
showDetails?: boolean;
/** Show override input field */
showOverrideInput?: boolean;
/** Callback when override value changes */
onOverrideChange?: (value: number | null) => void;
}
// Display-only constants (for showing the formula, NOT for calculation)
// The actual calculation happens in backend/src/services/effortCalculation.ts
const HOURS_PER_WEEK_DISPLAY = 36;
const WORK_WEEKS_PER_YEAR_DISPLAY = 46;
const DECLARABLE_PERCENTAGE_DISPLAY = 0.75;
export function EffortDisplay({
effectiveFte,
calculatedFte,
overrideFte,
breakdown,
isPreview = false,
showDetails = true,
showOverrideInput = false,
onOverrideChange,
}: EffortDisplayProps) {
const hasOverride = overrideFte !== null && overrideFte !== undefined;
const hasBreakdown = breakdown !== null && breakdown !== undefined;
// Extract breakdown values
const baseEffort = breakdown?.baseEffort ?? null;
const baseEffortMin = breakdown?.baseEffortMin ?? null;
const baseEffortMax = breakdown?.baseEffortMax ?? null;
const numberOfUsersFactor = breakdown?.numberOfUsersFactor ?? { value: 1.0, name: null };
const dynamicsFactor = breakdown?.dynamicsFactor ?? { value: 1.0, name: null };
const complexityFactor = breakdown?.complexityFactor ?? { value: 1.0, name: null };
const governanceModelName = breakdown?.governanceModelName ?? breakdown?.governanceModel ?? null;
const applicationTypeName = breakdown?.applicationType ?? null;
const businessImpactAnalyse = breakdown?.businessImpactAnalyse ?? null;
const applicationManagementHosting = breakdown?.applicationManagementHosting ?? null;
const warnings = breakdown?.warnings ?? [];
const errors = breakdown?.errors ?? [];
const usedDefaults = breakdown?.usedDefaults ?? [];
const requiresManualAssessment = breakdown?.requiresManualAssessment ?? false;
const isFixedFte = breakdown?.isFixedFte ?? false;
// Use hours from backend breakdown (calculated in effortCalculation.ts)
// Only fall back to local calculation if breakdown is not available
const declarableHoursPerYear = breakdown?.hoursPerYear ?? (effectiveFte !== null
? HOURS_PER_WEEK_DISPLAY * WORK_WEEKS_PER_YEAR_DISPLAY * effectiveFte * DECLARABLE_PERCENTAGE_DISPLAY
: 0);
const hoursPerMonth = breakdown?.hoursPerMonth ?? declarableHoursPerYear / 12;
const hoursPerWeekCalculated = breakdown?.hoursPerWeek ?? declarableHoursPerYear / WORK_WEEKS_PER_YEAR_DISPLAY;
const minutesPerWeek = hoursPerWeekCalculated * 60;
// For display of netto hours (before declarable percentage)
const netHoursPerYear = effectiveFte !== null
? HOURS_PER_WEEK_DISPLAY * WORK_WEEKS_PER_YEAR_DISPLAY * effectiveFte
: 0;
// No effort calculated
if (effectiveFte === null || effectiveFte === undefined) {
if (errors.length > 0) {
return (
<div className="space-y-2">
<div className="bg-red-50 border border-red-200 rounded-lg p-2">
{errors.map((error, i) => (
<div key={i} className="text-sm text-red-700 flex items-start gap-1">
<span></span>
<span>{error}</span>
</div>
))}
</div>
<span className="text-sm text-gray-400">Niet berekend - configuratie onvolledig</span>
</div>
);
}
return <span className="text-sm text-gray-400">Niet berekend</span>;
}
return (
<div className="space-y-2">
{/* Errors */}
{errors.length > 0 && (
<div className="bg-red-50 border border-red-200 rounded-lg p-2 mb-2">
{errors.map((error, i) => (
<div key={i} className="text-sm text-red-700 flex items-start gap-1">
<span></span>
<span>{error}</span>
</div>
))}
</div>
)}
{/* Warnings */}
{warnings.length > 0 && (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-2 mb-2">
{warnings.map((warning, i) => (
<div key={i} className="text-sm text-yellow-700 flex items-start gap-1">
<span>{warning.startsWith('⚠️') || warning.startsWith('') ? '' : ''}</span>
<span>{warning}</span>
</div>
))}
</div>
)}
{/* Main FTE display */}
<div className="text-lg font-semibold text-gray-900">
{effectiveFte.toFixed(2)} FTE
{hasOverride && (
<span className="ml-2 text-sm font-normal text-orange-600">(Override)</span>
)}
{isPreview && !hasOverride && (
<span className="ml-2 text-sm font-normal text-blue-600">(voorvertoning)</span>
)}
{isFixedFte && (
<span className="ml-2 text-sm font-normal text-purple-600">(vast)</span>
)}
{requiresManualAssessment && (
<span className="ml-2 text-sm font-normal text-orange-600">(handmatige beoordeling)</span>
)}
</div>
{/* Show calculated value if override is active */}
{hasOverride && calculatedFte !== null && calculatedFte !== undefined && (
<div className="text-sm text-gray-600">
Berekende waarde: <span className="font-medium">{calculatedFte.toFixed(2)} FTE</span>
</div>
)}
{/* Override input */}
{showOverrideInput && onOverrideChange && (
<div className="flex items-center gap-2 mt-2">
<label className="text-sm text-gray-600">Override FTE:</label>
<input
type="number"
step="0.01"
min="0"
value={overrideFte !== null ? overrideFte : ''}
onChange={(e) => {
const value = e.target.value;
if (value === '') {
onOverrideChange(null);
} else {
const numValue = parseFloat(value);
if (!isNaN(numValue)) {
onOverrideChange(numValue);
}
}
}}
className="w-24 px-2 py-1 border border-gray-300 rounded text-sm"
placeholder="Leeg"
/>
</div>
)}
{showDetails && baseEffort !== null && (
<div className="pt-2 border-t border-gray-200 space-y-1 text-sm text-gray-600">
{/* Base FTE with range */}
<div className="font-medium text-gray-700 mb-2">
Basis FTE: {baseEffort.toFixed(2)} FTE
{baseEffortMin !== null && baseEffortMax !== null && baseEffortMin !== baseEffortMax && (
<span className="text-xs text-gray-500 ml-1">
(range: {baseEffortMin.toFixed(2)} - {baseEffortMax.toFixed(2)})
</span>
)}
</div>
{/* Lookup path */}
<div className="pl-2 space-y-1 text-xs text-gray-500 border-l-2 border-gray-300">
<div className="flex items-center gap-1">
<span>ICT Governance Model:</span>
<span className="font-medium text-gray-700">{governanceModelName || 'Niet ingesteld'}</span>
{usedDefaults.includes('regiemodel') && <span className="text-orange-500">(default)</span>}
</div>
<div className="flex items-center gap-1">
<span>Application Type:</span>
<span className="font-medium text-gray-700">{applicationTypeName || 'Niet ingesteld'}</span>
{usedDefaults.includes('applicationType') && <span className="text-orange-500">(default)</span>}
</div>
<div className="flex items-center gap-1">
<span>Business Impact Analyse:</span>
<span className="font-medium text-gray-700">{businessImpactAnalyse || 'Niet ingesteld'}</span>
{usedDefaults.includes('businessImpact') && <span className="text-orange-500">(default)</span>}
</div>
<div className="flex items-center gap-1">
<span>Hosting:</span>
<span className="font-medium text-gray-700">{applicationManagementHosting || 'Niet ingesteld'}</span>
{usedDefaults.includes('hosting') && <span className="text-orange-500">(default)</span>}
</div>
</div>
{/* Factors */}
<div className="font-medium text-gray-700 mt-2 mb-1">Factoren:</div>
<div>
Number of Users: × {numberOfUsersFactor.value.toFixed(2)}
{numberOfUsersFactor.name && ` (${numberOfUsersFactor.name})`}
</div>
<div>
Dynamics Factor: × {dynamicsFactor.value.toFixed(2)}
{dynamicsFactor.name && ` (${dynamicsFactor.name})`}
</div>
<div>
Complexity Factor: × {complexityFactor.value.toFixed(2)}
{complexityFactor.name && ` (${complexityFactor.name})`}
</div>
{/* Hours breakdown */}
<div className="font-medium text-gray-700 mt-3 mb-1 pt-2 border-t border-gray-200">
Uren per jaar (écht inzetbaar):
</div>
<div className="pl-2 space-y-1 text-xs text-gray-600 bg-blue-50 rounded p-2 border-l-2 border-blue-300">
<div className="font-medium text-gray-700">
{declarableHoursPerYear.toFixed(1)} uur per jaar
</div>
<div className="text-gray-500 mt-1">
{hoursPerMonth.toFixed(1)} uur per maand
</div>
<div className="text-gray-500">
{hoursPerWeekCalculated.toFixed(2)} uur per week
</div>
<div className="text-gray-500">
{minutesPerWeek.toFixed(0)} minuten per week
</div>
<div className="text-xs text-gray-400 mt-2 pt-2 border-t border-gray-200">
<div>Berekening: {HOURS_PER_WEEK_DISPLAY} uur/week × {WORK_WEEKS_PER_YEAR_DISPLAY} weken × {effectiveFte.toFixed(2)} FTE × {DECLARABLE_PERCENTAGE_DISPLAY * 100}% = {declarableHoursPerYear.toFixed(1)} uur/jaar</div>
<div className="mt-1">(Netto: {netHoursPerYear.toFixed(1)} uur/jaar, waarvan {DECLARABLE_PERCENTAGE_DISPLAY * 100}% declarabel)</div>
</div>
</div>
</div>
)}
</div>
);
}
export default EffortDisplay;

View File

@@ -0,0 +1,337 @@
import { useEffect, useState, useMemo } from 'react';
import { getReferenceData } from '../services/api';
import CustomSelect from './CustomSelect';
import { EffortDisplay } from './EffortDisplay';
import { useEffortCalculation, getEffectiveFte } from '../hooks/useEffortCalculation';
import type { ReferenceValue, ApplicationDetails } from '../types';
export default function FTECalculator() {
// Reference data state
const [governanceModels, setGovernanceModels] = useState<ReferenceValue[]>([]);
const [applicationTypes, setApplicationTypes] = useState<ReferenceValue[]>([]);
const [businessImpactAnalyses, setBusinessImpactAnalyses] = useState<ReferenceValue[]>([]);
const [applicationManagementHosting, setApplicationManagementHosting] = useState<ReferenceValue[]>([]);
const [numberOfUsers, setNumberOfUsers] = useState<ReferenceValue[]>([]);
const [dynamicsFactors, setDynamicsFactors] = useState<ReferenceValue[]>([]);
const [complexityFactors, setComplexityFactors] = useState<ReferenceValue[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Selected values state
const [selectedGovernanceModel, setSelectedGovernanceModel] = useState<ReferenceValue | null>(null);
const [selectedApplicationType, setSelectedApplicationType] = useState<ReferenceValue | null>(null);
const [selectedBusinessImpactAnalyse, setSelectedBusinessImpactAnalyse] = useState<ReferenceValue | null>(null);
const [selectedHosting, setSelectedHosting] = useState<ReferenceValue | null>(null);
const [selectedNumberOfUsers, setSelectedNumberOfUsers] = useState<ReferenceValue | null>(null);
const [selectedDynamicsFactor, setSelectedDynamicsFactor] = useState<ReferenceValue | null>(null);
const [selectedComplexityFactor, setSelectedComplexityFactor] = useState<ReferenceValue | null>(null);
// Load reference data
useEffect(() => {
async function loadData() {
try {
setLoading(true);
setError(null);
const data = await getReferenceData();
setGovernanceModels(data.governanceModels);
setApplicationTypes(data.applicationTypes);
setBusinessImpactAnalyses(data.businessImpactAnalyses);
setApplicationManagementHosting(data.applicationManagementHosting);
setNumberOfUsers(data.numberOfUsers);
setDynamicsFactors(data.dynamicsFactors);
setComplexityFactors(data.complexityFactors);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load reference data');
} finally {
setLoading(false);
}
}
loadData();
}, []);
// Build a minimal ApplicationDetails object for calculation
const applicationData = useMemo<ApplicationDetails | null>(() => {
// Only create if at least governance model is selected (required for calculation)
if (!selectedGovernanceModel) return null;
return {
id: 'calculator',
key: 'CALC',
name: 'FTE Calculator',
searchReference: null,
description: null,
supplierProduct: null,
organisation: null,
hostingType: null,
status: null,
businessImportance: null,
businessImpactAnalyse: selectedBusinessImpactAnalyse,
systemOwner: null,
businessOwner: null,
functionalApplicationManagement: null,
technicalApplicationManagement: null,
medischeTechniek: false,
applicationFunctions: [],
dynamicsFactor: selectedDynamicsFactor,
complexityFactor: selectedComplexityFactor,
numberOfUsers: selectedNumberOfUsers,
governanceModel: selectedGovernanceModel,
applicationSubteam: null,
applicationTeam: null,
applicationType: selectedApplicationType,
platform: null,
requiredEffortApplicationManagement: null,
applicationManagementHosting: selectedHosting,
applicationManagementTAM: null,
};
}, [
selectedGovernanceModel,
selectedApplicationType,
selectedBusinessImpactAnalyse,
selectedHosting,
selectedNumberOfUsers,
selectedDynamicsFactor,
selectedComplexityFactor,
]);
// Use effort calculation hook
const {
calculatedFte,
breakdown: effortBreakdown,
isCalculating,
} = useEffortCalculation({
application: applicationData,
debounceMs: 300,
});
// Reset all fields
const handleReset = () => {
setSelectedGovernanceModel(null);
setSelectedApplicationType(null);
setSelectedBusinessImpactAnalyse(null);
setSelectedHosting(null);
setSelectedNumberOfUsers(null);
setSelectedDynamicsFactor(null);
setSelectedComplexityFactor(null);
};
// Sort numberOfUsers by extracting the first number from each option
const sortedNumberOfUsers = useMemo(() => {
const getSortValue = (name: string): number => {
const cleaned = name.replace(/\./g, '');
const match = cleaned.match(/\d+/);
const num = match ? parseInt(match[0], 10) : 0;
if (name.startsWith('<')) {
return num - 0.5;
}
if (name.startsWith('>')) {
return num + 0.5;
}
return num;
};
return [...numberOfUsers].sort((a, b) => {
const numA = getSortValue(a.name);
const numB = getSortValue(b.name);
return numA - numB;
});
}, [numberOfUsers]);
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 className="bg-white rounded-lg border border-gray-200 p-6">
<h1 className="text-2xl font-bold text-gray-900 mb-2">FTE Calculator</h1>
<p className="text-gray-600">
Bereken de benodigde FTE voor applicatiemanagement op basis van de onderstaande classificatievelden.
</p>
</div>
{/* Form */}
<div className="bg-white rounded-lg border border-gray-200 p-6">
<div className="flex justify-between items-center mb-6">
<h2 className="text-lg font-semibold text-gray-900">Classificatievelden</h2>
<button
onClick={handleReset}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
>
Reset alle velden
</button>
</div>
<div className="space-y-6">
{/* First row: Application Type, Hosting */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Application Type */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Application Type
</label>
<CustomSelect
value={selectedApplicationType?.objectId || ''}
onChange={(value) => {
const selected = applicationTypes.find((t) => t.objectId === value);
setSelectedApplicationType(selected || null);
}}
options={applicationTypes}
placeholder="Selecteer Application Type..."
showSummary={true}
/>
</div>
{/* Hosting */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Hosting
</label>
<CustomSelect
value={selectedHosting?.objectId || ''}
onChange={(value) => {
const selected = applicationManagementHosting.find((h) => h.objectId === value);
setSelectedHosting(selected || null);
}}
options={applicationManagementHosting}
placeholder="Selecteer Hosting..."
/>
</div>
</div>
{/* Second row: Business Impact Analyse - Full width */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Business Impact Analyse
</label>
<CustomSelect
value={selectedBusinessImpactAnalyse?.objectId || ''}
onChange={(value) => {
const selected = businessImpactAnalyses.find((b) => b.objectId === value);
setSelectedBusinessImpactAnalyse(selected || null);
}}
options={businessImpactAnalyses}
placeholder="Selecteer Business Impact Analyse..."
showSummary={true}
/>
</div>
{/* Third row: Number of Users, Dynamics Factor, Complexity Factor - 3 columns */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Number of Users */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Number of Users
</label>
<CustomSelect
value={selectedNumberOfUsers?.objectId || ''}
onChange={(value) => {
const selected = sortedNumberOfUsers.find((u) => u.objectId === value);
setSelectedNumberOfUsers(selected || null);
}}
options={sortedNumberOfUsers}
placeholder="Selecteer Number of Users..."
showSummary={true}
/>
</div>
{/* Dynamics Factor */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Dynamics Factor
</label>
<CustomSelect
value={selectedDynamicsFactor?.objectId || ''}
onChange={(value) => {
const selected = dynamicsFactors.find((d) => d.objectId === value);
setSelectedDynamicsFactor(selected || null);
}}
options={dynamicsFactors}
placeholder="Selecteer Dynamics Factor..."
showSummary={true}
/>
</div>
{/* Complexity Factor */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Complexity Factor
</label>
<CustomSelect
value={selectedComplexityFactor?.objectId || ''}
onChange={(value) => {
const selected = complexityFactors.find((c) => c.objectId === value);
setSelectedComplexityFactor(selected || null);
}}
options={complexityFactors}
placeholder="Selecteer Complexity Factor..."
showSummary={true}
/>
</div>
</div>
</div>
{/* ICT Governance Model - Full width at the end */}
<div className="mt-6">
<label className="block text-sm font-medium text-gray-700 mb-2">
ICT Governance Model <span className="text-red-500">*</span>
</label>
<CustomSelect
value={selectedGovernanceModel?.objectId || ''}
onChange={(value) => {
const selected = governanceModels.find((m) => m.objectId === value);
setSelectedGovernanceModel(selected || null);
}}
options={governanceModels}
placeholder="Selecteer ICT Governance Model..."
showSummary={true}
/>
</div>
</div>
{/* Result */}
<div className="bg-white rounded-lg border border-gray-200 p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Berekening Resultaat</h2>
{isCalculating ? (
<div className="flex items-center gap-2 text-gray-600">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600" />
<span>Berekenen...</span>
</div>
) : (
<div className="w-full border border-gray-300 rounded-lg px-3 py-2 bg-gray-50">
<EffortDisplay
effectiveFte={getEffectiveFte(calculatedFte, null, null)}
calculatedFte={calculatedFte ?? null}
overrideFte={null}
breakdown={effortBreakdown}
isPreview={true}
showDetails={true}
showOverrideInput={false}
/>
</div>
)}
{!selectedGovernanceModel && (
<p className="mt-4 text-sm text-gray-500">
Selecteer minimaal het ICT Governance Model om een berekening uit te voeren.
</p>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,303 @@
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
interface ApplicationWithIssues {
id: string;
key: string;
name: string;
status: string | null;
governanceModel: string | null;
businessImpactAnalyse: string | null;
applicationType: string | null;
warnings: string[];
errors: string[];
}
interface GovernanceAnalysisData {
totalApplications: number;
applicationsWithIssues: number;
applications: ApplicationWithIssues[];
}
const API_BASE = '/api';
// Default statuses to exclude
const DEFAULT_EXCLUDED_STATUSES = ['Closed', 'Deprecated'];
export default function GovernanceAnalysis() {
const [data, setData] = useState<GovernanceAnalysisData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [excludedStatuses, setExcludedStatuses] = useState<string[]>(DEFAULT_EXCLUDED_STATUSES);
useEffect(() => {
async function fetchData() {
setLoading(true);
setError(null);
try {
const response = await fetch(`${API_BASE}/dashboard/governance-analysis`);
if (!response.ok) {
throw new Error('Failed to fetch governance analysis');
}
const result = await response.json();
setData(result);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load data');
} finally {
setLoading(false);
}
}
fetchData();
}, []);
// Get unique statuses from the data for filtering
const availableStatuses = Array.from(
new Set(data?.applications.map(app => app.status).filter(Boolean) as string[])
).sort();
// Filter applications
const filteredApplications = data?.applications.filter(app => {
// Filter by excluded statuses
if (app.status && excludedStatuses.includes(app.status)) return false;
// Filter by search query
if (searchQuery) {
const query = searchQuery.toLowerCase();
return (
app.name.toLowerCase().includes(query) ||
app.key.toLowerCase().includes(query) ||
(app.governanceModel?.toLowerCase().includes(query)) ||
(app.businessImpactAnalyse?.toLowerCase().includes(query))
);
}
return true;
}) || [];
// Toggle status exclusion
const toggleStatusExclusion = (status: string) => {
setExcludedStatuses(prev =>
prev.includes(status)
? prev.filter(s => s !== status)
: [...prev, status]
);
};
// Applications filtered by status (before type/search filtering)
const statusFilteredApplications = data?.applications.filter(app =>
!(app.status && excludedStatuses.includes(app.status))
) || [];
// Count statistics based on status-filtered applications
const totalWithIssues = statusFilteredApplications.length;
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-4" />
<p className="text-gray-500">Analyseren van regiemodel configuratie...</p>
<p className="text-gray-400 text-sm mt-1">Dit kan even duren...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-red-700">
<div className="flex items-center gap-2">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{error}
</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex justify-between items-start">
<div>
<h1 className="text-2xl font-bold text-gray-900">Analyse Regiemodel</h1>
<p className="mt-1 text-gray-500">
Overzicht van applicaties met regiemodel fouten (ongeldig regiemodel voor de BIA classificatie).
<br />
<span className="text-sm text-gray-400">Standaard worden Closed en Deprecated applicaties uitgesloten.</span>
</p>
</div>
<Link
to="/reports"
className="text-sm text-blue-600 hover:text-blue-700 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="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Terug naar rapporten
</Link>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-white rounded-lg border border-gray-200 p-4">
<div className="text-sm text-gray-500">Totaal geanalyseerd</div>
<div className="text-2xl font-bold text-gray-900">{data?.totalApplications || 0}</div>
</div>
<div className="bg-white rounded-lg border border-gray-200 p-4">
<div className="text-sm text-gray-500">Met regiemodel fouten</div>
<div className="text-2xl font-bold text-red-600">{totalWithIssues}</div>
{excludedStatuses.length > 0 && data && totalWithIssues !== data.applicationsWithIssues && (
<div className="text-xs text-gray-400 mt-1">
({data.applicationsWithIssues} totaal, {data.applicationsWithIssues - totalWithIssues} uitgesloten)
</div>
)}
</div>
</div>
{/* Filters */}
<div className="bg-white rounded-lg border border-gray-200 p-4 space-y-4">
<div className="flex flex-wrap items-center gap-4">
{/* Search */}
<div className="flex-1 min-w-[200px]">
<div className="relative">
<input
type="text"
placeholder="Zoeken op naam, key, regiemodel..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
<svg
className="absolute left-3 top-2.5 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>
</div>
</div>
{/* Status exclusion filter */}
{availableStatuses.length > 0 && (
<div className="pt-2 border-t border-gray-100">
<div className="flex flex-wrap items-center gap-3">
<span className="text-sm text-gray-600">Status uitsluiten:</span>
{availableStatuses.map((status) => (
<label key={status} className="flex items-center gap-1.5 cursor-pointer">
<input
type="checkbox"
checked={excludedStatuses.includes(status)}
onChange={() => toggleStatusExclusion(status)}
className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className={`text-sm ${excludedStatuses.includes(status) ? 'text-gray-400 line-through' : 'text-gray-700'}`}>
{status}
</span>
</label>
))}
{excludedStatuses.length > 0 && (
<button
onClick={() => setExcludedStatuses([])}
className="text-xs text-blue-600 hover:text-blue-800 ml-2"
>
Alles tonen
</button>
)}
</div>
</div>
)}
</div>
{/* Results */}
<div className="bg-white rounded-lg border border-gray-200">
<div className="px-4 py-3 border-b border-gray-200">
<span className="text-sm text-gray-600">
{filteredApplications.length} applicatie{filteredApplications.length !== 1 ? 's' : ''} gevonden
</span>
</div>
{filteredApplications.length === 0 ? (
<div className="text-center py-12">
<svg className="w-12 h-12 text-green-300 mx-auto mb-4" 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>
<p className="text-gray-500">Geen applicaties met regiemodel fouten gevonden</p>
<p className="text-gray-400 text-sm mt-1">Alle applicaties hebben een geldig regiemodel voor hun BIA classificatie</p>
</div>
) : (
<div className="divide-y divide-gray-200">
{filteredApplications.map((app) => (
<div key={app.id} className="p-4 hover:bg-gray-50">
<div className="flex items-start justify-between">
<div className="flex-1">
<Link
to={`/app-components/overview/${app.id}`}
className="text-blue-600 hover:text-blue-800 font-medium hover:underline"
>
{app.name}
</Link>
<div className="flex flex-wrap items-center gap-2 mt-1 text-sm text-gray-500">
<span className="font-mono">{app.key}</span>
{app.status && (
<span className="px-2 py-0.5 bg-gray-100 rounded text-xs">{app.status}</span>
)}
{app.governanceModel && (
<span className="px-2 py-0.5 bg-blue-100 text-blue-700 rounded text-xs">
{app.governanceModel}
</span>
)}
{app.businessImpactAnalyse && (
<span className="px-2 py-0.5 bg-purple-100 text-purple-700 rounded text-xs">
BIA: {app.businessImpactAnalyse}
</span>
)}
{app.applicationType && (
<span className="px-2 py-0.5 bg-green-100 text-green-700 rounded text-xs">
{app.applicationType}
</span>
)}
</div>
</div>
</div>
{/* Errors */}
{app.errors.length > 0 && (
<div className="mt-3 bg-red-50 border border-red-200 rounded-lg p-3">
{app.errors.map((error, i) => (
<div key={i} className="text-sm text-red-700 flex items-start gap-2">
<span className="flex-shrink-0"></span>
<span>{error}</span>
</div>
))}
</div>
)}
{/* Warnings */}
{app.warnings.length > 0 && (
<div className="mt-3 bg-yellow-50 border border-yellow-200 rounded-lg p-3">
{app.warnings.map((warning, i) => (
<div key={i} className="text-sm text-yellow-700 flex items-start gap-2">
<span className="flex-shrink-0">
{warning.startsWith('⚠️') || warning.startsWith('') ? '' : ''}
</span>
<span>{warning}</span>
</div>
))}
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -63,7 +63,7 @@ export default function Login() {
</div>
)}
{config?.oauthEnabled ? (
{config?.authMethod === 'oauth' ? (
<>
<button
onClick={handleJiraLogin}
@@ -76,19 +76,19 @@ export default function Login() {
</button>
<p className="mt-4 text-center text-slate-500 text-sm">
Je wordt doorgestuurd naar Jira om in te loggen
Je wordt doorgestuurd naar Jira om in te loggen met OAuth 2.0
</p>
</>
) : config?.serviceAccountEnabled ? (
) : config?.authMethod === 'pat' ? (
<div className="text-center">
<div className="inline-flex items-center justify-center w-12 h-12 bg-green-500/20 rounded-full mb-4">
<svg className="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<p className="text-slate-300 mb-2">Service Account Modus</p>
<p className="text-slate-300 mb-2">Personal Access Token Modus</p>
<p className="text-slate-500 text-sm">
De applicatie gebruikt een geconfigureerd service account voor Jira toegang.
De applicatie gebruikt een geconfigureerd Personal Access Token (PAT) voor Jira toegang.
</p>
<button
onClick={() => window.location.reload()}
@@ -106,7 +106,7 @@ export default function Login() {
</div>
<p className="text-slate-300 mb-2">Niet geconfigureerd</p>
<p className="text-slate-500 text-sm">
Neem contact op met de beheerder om OAuth of een service account te configureren.
Neem contact op met de beheerder om OAuth of een Personal Access Token te configureren.
</p>
</div>
)}

View File

@@ -0,0 +1,165 @@
import { Link } from 'react-router-dom';
export default function ReportsDashboard() {
const reports = [
{
id: 'team-dashboard',
title: 'Team-indeling',
description: 'Overzicht van teams, subteams en FTE verdeling.',
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
),
href: '/reports/team-dashboard',
color: 'blue',
available: true,
},
{
id: 'governance-analysis',
title: 'Analyse Regiemodel',
description: 'Overzicht van applicaties met regiemodel en BIA configuratie problemen.',
icon: (
<svg className="w-6 h-6" 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>
),
href: '/reports/governance-analysis',
color: 'orange',
available: true,
},
{
id: 'classification-progress',
title: 'Classificatie Voortgang',
description: 'Voortgang van ZiRA classificatie per domein en afdeling.',
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/classification-progress',
color: 'green',
available: false,
},
{
id: 'governance-overview',
title: 'Regiemodel Overzicht',
description: 'Verdeling van applicaties per regiemodel en 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="M11 3.055A9.001 9.001 0 1020.945 13H11V3.055z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.488 9H15V3.512A9.025 9.025 0 0120.488 9z" />
</svg>
),
href: '/reports/governance-overview',
color: 'purple',
available: false,
},
{
id: 'data-model',
title: 'Datamodel',
description: 'Overzicht van alle object types, attributen en relaties in het Jira Assets schema.',
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
</svg>
),
href: '/reports/data-model',
color: 'cyan',
available: true,
},
];
const colorClasses = {
blue: {
bg: 'bg-blue-50',
iconBg: 'bg-blue-100',
iconText: 'text-blue-600',
hover: 'hover:bg-blue-100',
},
green: {
bg: 'bg-green-50',
iconBg: 'bg-green-100',
iconText: 'text-green-600',
hover: 'hover:bg-green-100',
},
purple: {
bg: 'bg-purple-50',
iconBg: 'bg-purple-100',
iconText: 'text-purple-600',
hover: 'hover:bg-purple-100',
},
orange: {
bg: 'bg-orange-50',
iconBg: 'bg-orange-100',
iconText: 'text-orange-600',
hover: 'hover:bg-orange-100',
},
cyan: {
bg: 'bg-cyan-50',
iconBg: 'bg-cyan-100',
iconText: 'text-cyan-600',
hover: 'hover:bg-cyan-100',
},
};
return (
<div>
{/* Header */}
<div className="mb-8">
<h1 className="text-2xl font-bold text-gray-900">Rapporten</h1>
<p className="mt-1 text-gray-500">
Overzicht van beschikbare rapporten en analyses voor de CMDB.
</p>
</div>
{/* Reports Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{reports.map((report) => {
const colors = colorClasses[report.color as keyof typeof colorClasses];
if (!report.available) {
return (
<div
key={report.id}
className={`relative p-6 rounded-xl border border-gray-200 bg-gray-50 opacity-60`}
>
<div className="absolute top-4 right-4">
<span className="px-2 py-1 text-xs font-medium bg-gray-200 text-gray-600 rounded-full">
Binnenkort
</span>
</div>
<div className={`w-12 h-12 rounded-xl ${colors.iconBg} flex items-center justify-center mb-4`}>
<span className={colors.iconText}>{report.icon}</span>
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">{report.title}</h3>
<p className="text-gray-500 text-sm">{report.description}</p>
</div>
);
}
return (
<Link
key={report.id}
to={report.href}
className={`p-6 rounded-xl border border-gray-200 ${colors.bg} ${colors.hover} transition-colors group`}
>
<div className={`w-12 h-12 rounded-xl ${colors.iconBg} flex items-center justify-center mb-4 group-hover:scale-110 transition-transform`}>
<span className={colors.iconText}>{report.icon}</span>
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">{report.title}</h3>
<p className="text-gray-500 text-sm">{report.description}</p>
<div className="mt-4 flex items-center text-sm font-medium text-blue-600">
Bekijk rapport
<svg className="w-4 h-4 ml-1 group-hover:translate-x-1 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
</Link>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,548 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { searchCMDB, getConfig, CMDBSearchResponse, CMDBSearchResult, CMDBSearchObjectType } from '../services/api';
const ITEMS_PER_PAGE = 25;
const APPLICATION_COMPONENT_TYPE_NAME = 'ApplicationComponent';
// Helper to strip HTML tags from description
function stripHtml(html: string): string {
return html
.replace(/<[^>]*>/g, '')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/\s+/g, ' ')
.trim();
}
// Helper to get attribute value from result
function getAttributeValue(result: CMDBSearchResult, attributeName: string): string | null {
const attr = result.attributes.find(a => a.name === attributeName);
if (attr && attr.values && attr.values.length > 0) {
return attr.values[0];
}
return null;
}
// Helper to get status display info
function getStatusInfo(status: string | null): { color: string; bg: string } {
if (!status) return { color: 'text-gray-600', bg: 'bg-gray-100' };
const statusLower = status.toLowerCase();
if (statusLower.includes('production')) return { color: 'text-green-700', bg: 'bg-green-100' };
if (statusLower.includes('implementation')) return { color: 'text-blue-700', bg: 'bg-blue-100' };
if (statusLower.includes('deprecated') || statusLower.includes('end of')) return { color: 'text-orange-700', bg: 'bg-orange-100' };
if (statusLower.includes('closed')) return { color: 'text-red-700', bg: 'bg-red-100' };
if (statusLower.includes('concept') || statusLower.includes('poc')) return { color: 'text-purple-700', bg: 'bg-purple-100' };
if (statusLower.includes('shadow')) return { color: 'text-yellow-700', bg: 'bg-yellow-100' };
return { color: 'text-gray-600', bg: 'bg-gray-100' };
}
export default function SearchDashboard() {
const navigate = useNavigate();
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<CMDBSearchResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedTab, setSelectedTab] = useState<number | null>(null);
const [statusFilter, setStatusFilter] = useState<string>('');
const [currentPage, setCurrentPage] = useState<Map<number, number>>(new Map());
const [jiraHost, setJiraHost] = useState<string>('');
const [hasSearched, setHasSearched] = useState(false);
// Fetch Jira host for avatar URLs
useEffect(() => {
getConfig().then(config => {
setJiraHost(config.jiraHost);
}).catch(() => {
// Silently fail, avatars just won't show
});
}, []);
// Group results by object type
const resultsByType = useMemo(() => {
if (!searchResults?.results) return new Map<number, CMDBSearchResult[]>();
const grouped = new Map<number, CMDBSearchResult[]>();
for (const result of searchResults.results) {
const typeId = result.objectTypeId;
if (!grouped.has(typeId)) {
grouped.set(typeId, []);
}
grouped.get(typeId)!.push(result);
}
return grouped;
}, [searchResults]);
// Get object type info by ID
const objectTypeMap = useMemo(() => {
if (!searchResults?.objectTypes) return new Map<number, CMDBSearchObjectType>();
const map = new Map<number, CMDBSearchObjectType>();
for (const ot of searchResults.objectTypes) {
map.set(ot.id, ot);
}
return map;
}, [searchResults]);
// Get sorted object types (by result count, descending)
const sortedObjectTypes = useMemo(() => {
if (!searchResults?.objectTypes) return [];
return [...searchResults.objectTypes].sort((a, b) => {
const countA = resultsByType.get(a.id)?.length || 0;
const countB = resultsByType.get(b.id)?.length || 0;
return countB - countA;
});
}, [searchResults, resultsByType]);
// Current tab's results
const currentTabResults = useMemo(() => {
if (selectedTab === null) return [];
return resultsByType.get(selectedTab) || [];
}, [selectedTab, resultsByType]);
// Get unique status values for current tab
const statusOptions = useMemo(() => {
const statuses = new Set<string>();
for (const result of currentTabResults) {
const status = getAttributeValue(result, 'Status');
if (status) {
// Handle status objects with nested structure (null check required because typeof null === 'object')
const statusName = status && typeof status === 'object' && (status as any).name
? (status as any).name
: status;
statuses.add(statusName);
}
}
return Array.from(statuses).sort();
}, [currentTabResults]);
// Filter results by status
const filteredResults = useMemo(() => {
if (!statusFilter) return currentTabResults;
return currentTabResults.filter(result => {
const status = getAttributeValue(result, 'Status');
if (!status) return false;
// Handle status objects with nested structure (null check required because typeof null === 'object')
const statusName = status && typeof status === 'object' && (status as any).name
? (status as any).name
: status;
return statusName === statusFilter;
});
}, [currentTabResults, statusFilter]);
// Pagination
const pageForCurrentTab = currentPage.get(selectedTab || 0) || 1;
const totalPages = Math.ceil(filteredResults.length / ITEMS_PER_PAGE);
const paginatedResults = filteredResults.slice(
(pageForCurrentTab - 1) * ITEMS_PER_PAGE,
pageForCurrentTab * ITEMS_PER_PAGE
);
// Reset pagination when filter changes
useEffect(() => {
if (selectedTab !== null) {
setCurrentPage(prev => new Map(prev).set(selectedTab, 1));
}
}, [statusFilter, selectedTab]);
// Auto-select first tab when results arrive
useEffect(() => {
if (sortedObjectTypes.length > 0 && selectedTab === null) {
setSelectedTab(sortedObjectTypes[0].id);
}
}, [sortedObjectTypes, selectedTab]);
// Perform search
const handleSearch = useCallback((e?: React.FormEvent) => {
e?.preventDefault();
if (!searchQuery.trim()) return;
setLoading(true);
setError(null);
setHasSearched(true);
setSelectedTab(null);
setStatusFilter('');
setCurrentPage(new Map());
searchCMDB(searchQuery.trim())
.then((results) => {
setSearchResults(results);
// Auto-select first tab if results exist
if (results.objectTypes && results.objectTypes.length > 0) {
// Sort by count and select the first one
const sorted = [...results.objectTypes].sort((a, b) => {
const countA = results.results.filter(r => r.objectTypeId === a.id).length;
const countB = results.results.filter(r => r.objectTypeId === b.id).length;
return countB - countA;
});
setSelectedTab(sorted[0].id);
}
})
.catch((err) => {
setError(err instanceof Error ? err.message : 'Zoeken mislukt');
setSearchResults(null);
})
.finally(() => {
setLoading(false);
});
}, [searchQuery]);
// Handle tab change
const handleTabChange = (typeId: number) => {
setSelectedTab(typeId);
setStatusFilter('');
};
// Handle page change
const handlePageChange = (newPage: number) => {
if (selectedTab !== null) {
setCurrentPage(prev => new Map(prev).set(selectedTab, newPage));
}
};
// Helper to check if a result is an Application Component (by looking up type name)
const isApplicationComponent = useCallback((result: CMDBSearchResult) => {
const objectType = objectTypeMap.get(result.objectTypeId);
return objectType?.name === APPLICATION_COMPONENT_TYPE_NAME;
}, [objectTypeMap]);
// Handle result click (for Application Components)
const handleResultClick = (result: CMDBSearchResult) => {
if (isApplicationComponent(result)) {
navigate(`/application/${result.id}`);
}
};
// Get avatar URL with Jira host prefix
const getAvatarUrl = (avatarUrl: string) => {
if (!avatarUrl) return null;
if (avatarUrl.startsWith('http')) return avatarUrl;
return `${jiraHost}${avatarUrl}`;
};
return (
<div className="space-y-6">
{/* Header */}
<div className="text-center">
<div className="inline-flex items-center justify-center w-14 h-14 bg-gradient-to-br from-blue-500 to-blue-600 rounded-2xl mb-4 shadow-lg">
<svg className="w-7 h-7 text-white" 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>
<h1 className="text-2xl font-bold text-gray-900 mb-1">CMDB Zoeken</h1>
<p className="text-gray-500 text-sm">
Zoek naar applicaties, servers, infrastructuur en andere items in de CMDB.
</p>
</div>
{/* Search Form */}
<form onSubmit={handleSearch} className="max-w-3xl mx-auto">
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-4 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="Zoek op naam, key, of beschrijving..."
className="w-full pl-12 pr-28 py-3.5 text-base border-2 border-gray-200 rounded-xl focus:border-blue-500 focus:ring-4 focus:ring-blue-100 transition-all outline-none"
disabled={loading}
/>
<button
type="submit"
disabled={loading || !searchQuery.trim()}
className="absolute inset-y-1.5 right-1.5 px-5 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-medium rounded-lg transition-colors flex items-center gap-2"
>
{loading && (
<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>
)}
Zoeken
</button>
</div>
</form>
{/* Error Message */}
{error && (
<div className="max-w-3xl mx-auto bg-red-50 border border-red-200 rounded-lg p-4 text-red-700">
<div className="flex items-center gap-2">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{error}
</div>
</div>
)}
{/* Results */}
{hasSearched && searchResults && !loading && (
<div className="space-y-4">
{/* Results Summary */}
<div className="flex items-center justify-between">
<p className="text-sm text-gray-600">
<span className="font-medium">{searchResults.metadata.total}</span> resultaten gevonden
{searchResults.metadata.total !== searchResults.results.length && (
<span className="text-gray-400"> (eerste {searchResults.results.length} getoond)</span>
)}
</p>
</div>
{searchResults.results.length === 0 ? (
<div className="text-center py-12 bg-gray-50 rounded-xl">
<svg className="w-12 h-12 text-gray-300 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} 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">Geen resultaten gevonden voor "{searchQuery}"</p>
<p className="text-gray-400 text-sm mt-1">Probeer een andere zoekterm</p>
</div>
) : (
<>
{/* Object Type Tabs */}
<div className="border-b border-gray-200">
<nav className="-mb-px flex space-x-1 overflow-x-auto pb-px" aria-label="Tabs">
{sortedObjectTypes.map((objectType) => {
const count = resultsByType.get(objectType.id)?.length || 0;
const isActive = selectedTab === objectType.id;
return (
<button
key={objectType.id}
onClick={() => handleTabChange(objectType.id)}
className={`
flex items-center gap-2 whitespace-nowrap py-3 px-4 border-b-2 text-sm font-medium transition-colors
${isActive
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'}
`}
>
{jiraHost && objectType.iconUrl && (
<img
src={getAvatarUrl(objectType.iconUrl) || ''}
alt=""
className="w-4 h-4"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
/>
)}
<span>{objectType.name}</span>
<span className={`
px-2 py-0.5 text-xs rounded-full
${isActive ? 'bg-blue-100 text-blue-600' : 'bg-gray-100 text-gray-600'}
`}>
{count}
</span>
</button>
);
})}
</nav>
</div>
{/* Status Filter */}
{statusOptions.length > 0 && (
<div className="flex items-center gap-3">
<label className="text-sm text-gray-600">Filter op status:</label>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="text-sm border border-gray-300 rounded-lg px-3 py-1.5 focus:border-blue-500 focus:ring-2 focus:ring-blue-100 outline-none"
>
<option value="">Alle statussen ({currentTabResults.length})</option>
{statusOptions.map(status => {
const count = currentTabResults.filter(r => {
const s = getAttributeValue(r, 'Status');
const sName = s && typeof s === 'object' && (s as any).name ? (s as any).name : s;
return sName === status;
}).length;
return (
<option key={status} value={status}>
{status} ({count})
</option>
);
})}
</select>
{statusFilter && (
<button
onClick={() => setStatusFilter('')}
className="text-sm text-blue-600 hover:text-blue-700"
>
Wis filter
</button>
)}
</div>
)}
{/* Results List */}
<div className="space-y-2">
{paginatedResults.map((result) => {
const status = getAttributeValue(result, 'Status');
// Handle status objects with nested structure (null check required because typeof null === 'object')
const statusDisplay = status && typeof status === 'object' && (status as any).name
? (status as any).name
: status;
const statusInfo = getStatusInfo(statusDisplay);
const description = getAttributeValue(result, 'Description');
const isClickable = isApplicationComponent(result);
return (
<div
key={result.id}
onClick={() => isClickable && handleResultClick(result)}
className={`
bg-white border border-gray-200 rounded-lg p-4
${isClickable
? 'cursor-pointer hover:border-blue-300 hover:shadow-sm transition-all'
: ''}
`}
>
<div className="flex items-start gap-3">
{/* Avatar */}
<div className="flex-shrink-0 w-10 h-10 bg-gray-100 rounded-lg flex items-center justify-center overflow-hidden">
{result.avatarUrl && jiraHost ? (
<img
src={getAvatarUrl(result.avatarUrl) || ''}
alt=""
className="w-6 h-6"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
(e.target as HTMLImageElement).parentElement!.innerHTML = `
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6z" />
</svg>
`;
}}
/>
) : (
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6z" />
</svg>
)}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-gray-400 font-mono">{result.key}</span>
{statusDisplay && (
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${statusInfo.bg} ${statusInfo.color}`}>
{statusDisplay}
</span>
)}
{isClickable && (
<span className="text-xs text-blue-500 flex items-center gap-1">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
Klik om te openen
</span>
)}
</div>
<h3 className="font-medium text-gray-900 mt-0.5">{result.label}</h3>
{description && (
<p className="text-sm text-gray-500 mt-1 line-clamp-2">
{stripHtml(description).substring(0, 200)}
{stripHtml(description).length > 200 && '...'}
</p>
)}
</div>
</div>
</div>
);
})}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between pt-4 border-t border-gray-200">
<p className="text-sm text-gray-600">
Pagina {pageForCurrentTab} van {totalPages} ({filteredResults.length} items)
</p>
<div className="flex items-center gap-2">
<button
onClick={() => handlePageChange(pageForCurrentTab - 1)}
disabled={pageForCurrentTab === 1}
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Vorige
</button>
<button
onClick={() => handlePageChange(pageForCurrentTab + 1)}
disabled={pageForCurrentTab === totalPages}
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Volgende
</button>
</div>
</div>
)}
</>
)}
</div>
)}
{/* Quick Links (only show when no search has been performed) */}
{!hasSearched && (
<div className="mt-8 grid grid-cols-1 sm:grid-cols-3 gap-4 max-w-2xl mx-auto">
<a
href="/app-components"
className="flex items-center gap-3 p-4 bg-gray-50 hover:bg-gray-100 rounded-xl transition-colors group"
>
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center group-hover:bg-blue-200 transition-colors">
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
</svg>
</div>
<div>
<p className="font-medium text-gray-900">Application Components</p>
<p className="text-sm text-gray-500">Dashboard & overzicht</p>
</div>
</a>
<a
href="/reports/team-dashboard"
className="flex items-center gap-3 p-4 bg-gray-50 hover:bg-gray-100 rounded-xl transition-colors group"
>
<div className="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center group-hover:bg-green-200 transition-colors">
<svg className="w-5 h-5 text-green-600" 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>
</div>
<div>
<p className="font-medium text-gray-900">Rapporten</p>
<p className="text-sm text-gray-500">Team-indeling & analyses</p>
</div>
</a>
<a
href="/app-components/fte-config"
className="flex items-center gap-3 p-4 bg-gray-50 hover:bg-gray-100 rounded-xl transition-colors group"
>
<div className="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center group-hover:bg-purple-200 transition-colors">
<svg className="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<div>
<p className="font-medium text-gray-900">Configuratie</p>
<p className="text-sm text-gray-500">FTE berekening</p>
</div>
</a>
</div>
)}
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,212 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { calculateEffort } from '../services/api';
import type { ApplicationDetails, EffortCalculationBreakdown, ReferenceValue } from '../types';
export interface EffortCalculationResult {
/** The calculated FTE value (after all factors applied) */
calculatedFte: number | null;
/** The full breakdown of the calculation */
breakdown: EffortCalculationBreakdown | null;
/** Whether a calculation is in progress */
isCalculating: boolean;
/** Any error that occurred during calculation */
error: string | null;
/** Manually trigger a recalculation */
recalculate: () => Promise<void>;
}
export interface EffortCalculationOverrides {
governanceModel?: ReferenceValue | null;
applicationType?: ReferenceValue | null;
businessImpactAnalyse?: ReferenceValue | null;
applicationManagementHosting?: ReferenceValue | null;
dynamicsFactor?: ReferenceValue | null;
complexityFactor?: ReferenceValue | null;
numberOfUsers?: ReferenceValue | null;
hostingType?: ReferenceValue | null;
applicationFunctions?: ReferenceValue[];
}
export interface EffortCalculationInput {
/** Base application data */
application: ApplicationDetails | null;
/** Optional overrides for real-time preview (e.g., when user changes fields) */
overrides?: EffortCalculationOverrides;
/** Whether to auto-calculate on input changes (default: true) */
autoCalculate?: boolean;
/** Debounce delay in milliseconds (default: 150) */
debounceMs?: number;
}
/**
* Build application data with overrides applied.
* Exported for use in components that need manual control.
*/
export function buildApplicationDataWithOverrides(
application: ApplicationDetails,
overrides?: EffortCalculationOverrides
): ApplicationDetails {
if (!overrides) return application;
return {
...application,
governanceModel: overrides.governanceModel !== undefined
? overrides.governanceModel
: application.governanceModel,
applicationType: overrides.applicationType !== undefined
? overrides.applicationType
: application.applicationType,
businessImpactAnalyse: overrides.businessImpactAnalyse !== undefined
? overrides.businessImpactAnalyse
: application.businessImpactAnalyse,
applicationManagementHosting: overrides.applicationManagementHosting !== undefined
? overrides.applicationManagementHosting
: application.applicationManagementHosting,
dynamicsFactor: overrides.dynamicsFactor !== undefined
? overrides.dynamicsFactor
: application.dynamicsFactor,
complexityFactor: overrides.complexityFactor !== undefined
? overrides.complexityFactor
: application.complexityFactor,
numberOfUsers: overrides.numberOfUsers !== undefined
? overrides.numberOfUsers
: application.numberOfUsers,
hostingType: overrides.hostingType !== undefined
? overrides.hostingType
: application.hostingType,
applicationFunctions: overrides.applicationFunctions !== undefined
? overrides.applicationFunctions
: application.applicationFunctions,
};
}
/**
* Custom hook for FTE effort calculation.
* Centralizes the logic for calculating application management effort.
*
* Usage (simple - auto-calculate when application changes):
* ```tsx
* const { calculatedFte, breakdown } = useEffortCalculation({ application });
* ```
*
* Usage (with overrides for form preview):
* ```tsx
* const { calculatedFte, breakdown } = useEffortCalculation({
* application,
* overrides: { governanceModel: selectedGovernanceModel },
* debounceMs: 300,
* });
* ```
*/
export function useEffortCalculation({
application,
overrides,
autoCalculate = true,
debounceMs = 150,
}: EffortCalculationInput): EffortCalculationResult {
const [calculatedFte, setCalculatedFte] = useState<number | null>(null);
const [breakdown, setBreakdown] = useState<EffortCalculationBreakdown | null>(null);
const [isCalculating, setIsCalculating] = useState(false);
const [error, setError] = useState<string | null>(null);
// Track mounted state to prevent state updates after unmount
const isMountedRef = useRef(true);
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);
// Build the application data with any overrides applied
const applicationData = useMemo(() => {
if (!application) return null;
return buildApplicationDataWithOverrides(application, overrides);
}, [application, overrides]);
// Perform the calculation
const performCalculation = useCallback(async () => {
if (!applicationData) {
setCalculatedFte(null);
setBreakdown(null);
return;
}
setIsCalculating(true);
setError(null);
try {
const result = await calculateEffort(applicationData);
if (isMountedRef.current) {
setCalculatedFte(result.requiredEffortApplicationManagement);
setBreakdown(result.breakdown);
}
} catch (err) {
if (isMountedRef.current) {
console.error('Failed to calculate effort:', err);
setError(err instanceof Error ? err.message : 'Calculation failed');
setCalculatedFte(null);
setBreakdown(null);
}
} finally {
if (isMountedRef.current) {
setIsCalculating(false);
}
}
}, [applicationData]);
// Auto-calculate when dependencies change
useEffect(() => {
if (!autoCalculate) return;
if (!application) {
setCalculatedFte(null);
setBreakdown(null);
return;
}
// Debounce to prevent excessive API calls
const timeoutId = setTimeout(() => {
performCalculation();
}, debounceMs);
return () => clearTimeout(timeoutId);
}, [autoCalculate, application, applicationData, debounceMs, performCalculation]);
// Reset when application ID changes
useEffect(() => {
setCalculatedFte(null);
setBreakdown(null);
setError(null);
}, [application?.id]);
return {
calculatedFte,
breakdown,
isCalculating,
error,
recalculate: performCalculation,
};
}
/**
* Get the effective FTE value considering override
*/
export function getEffectiveFte(
calculatedFte: number | null,
overrideFte: number | null | undefined,
fallbackFte: number | null | undefined
): number | null {
// If override is set, use it
if (overrideFte !== null && overrideFte !== undefined) {
return overrideFte;
}
// Otherwise use calculated value
if (calculatedFte !== null) {
return calculatedFte;
}
// Fallback to stored value
return fallbackFte ?? null;
}

View File

@@ -14,6 +14,48 @@ import type {
const API_BASE = '/api';
// =============================================================================
// Error Types
// =============================================================================
export interface ConflictError {
status: 'conflict';
message: string;
conflicts?: Array<{
field: string;
fieldName: string;
proposedValue: unknown;
jiraValue: unknown;
}>;
jiraUpdatedAt?: string;
canMerge?: boolean;
warning?: string;
actions: {
forceOverwrite: boolean;
merge: boolean;
discard: boolean;
};
}
export class ApiError extends Error {
constructor(
message: string,
public status: number,
public data?: unknown
) {
super(message);
this.name = 'ApiError';
}
isConflict(): this is ApiError & { data: ConflictError } {
return this.status === 409;
}
}
// =============================================================================
// Base Fetch
// =============================================================================
async function fetchApi<T>(
endpoint: string,
options: RequestInit = {}
@@ -27,14 +69,21 @@ async function fetchApi<T>(
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(error.error || error.message || 'API request failed');
const errorData = await response.json().catch(() => ({ error: 'Unknown error' }));
throw new ApiError(
errorData.error || errorData.message || 'API request failed',
response.status,
errorData
);
}
return response.json();
}
// =============================================================================
// Applications
// =============================================================================
export async function searchApplications(
filters: SearchFilters,
page: number = 1,
@@ -50,6 +99,49 @@ export async function getApplicationById(id: string): Promise<ApplicationDetails
return fetchApi<ApplicationDetails>(`/applications/${id}`);
}
/**
* Get application for editing (force refresh from Jira)
* Returns fresh data with _jiraUpdatedAt for conflict detection
*/
export async function getApplicationForEdit(id: string): Promise<ApplicationDetails> {
return fetchApi<ApplicationDetails>(`/applications/${id}?mode=edit`);
}
// Related objects response type
export interface RelatedObject {
id: number;
key: string;
name: string;
label: string;
attributes: Record<string, string | null>;
}
export interface RelatedObjectsResponse {
objects: RelatedObject[];
total: number;
}
export async function getRelatedObjects(
applicationId: string,
objectType: string,
attributes?: string[]
): Promise<RelatedObjectsResponse> {
const params = attributes && attributes.length > 0
? `?attributes=${encodeURIComponent(attributes.join(','))}`
: '';
return fetchApi<RelatedObjectsResponse>(`/applications/${applicationId}/related/${objectType}${params}`);
}
export interface UpdateApplicationOptions {
/** The _jiraUpdatedAt from when the application was loaded for editing */
originalUpdatedAt?: string;
}
/**
* Update application with optional conflict detection
*
* @throws {ApiError} with status 409 if there's a conflict
*/
export async function updateApplication(
id: string,
updates: {
@@ -58,7 +150,41 @@ export async function updateApplication(
complexityFactor?: ReferenceValue;
numberOfUsers?: ReferenceValue;
governanceModel?: ReferenceValue;
applicationCluster?: ReferenceValue;
applicationSubteam?: ReferenceValue;
applicationTeam?: ReferenceValue;
applicationType?: ReferenceValue;
hostingType?: ReferenceValue;
businessImpactAnalyse?: ReferenceValue;
applicationManagementHosting?: string;
applicationManagementTAM?: string;
overrideFTE?: number | null;
source?: 'AI_ACCEPTED' | 'AI_MODIFIED' | 'MANUAL';
},
options?: UpdateApplicationOptions
): Promise<ApplicationDetails> {
const body = options?.originalUpdatedAt
? { updates, _jiraUpdatedAt: options.originalUpdatedAt }
: updates;
return fetchApi<ApplicationDetails>(`/applications/${id}`, {
method: 'PUT',
body: JSON.stringify(body),
});
}
/**
* Force update application (ignore conflicts)
*/
export async function forceUpdateApplication(
id: string,
updates: {
applicationFunctions?: ReferenceValue[];
dynamicsFactor?: ReferenceValue;
complexityFactor?: ReferenceValue;
numberOfUsers?: ReferenceValue;
governanceModel?: ReferenceValue;
applicationSubteam?: ReferenceValue;
applicationTeam?: ReferenceValue;
applicationType?: ReferenceValue;
hostingType?: ReferenceValue;
businessImpactAnalyse?: ReferenceValue;
@@ -68,7 +194,7 @@ export async function updateApplication(
source?: 'AI_ACCEPTED' | 'AI_MODIFIED' | 'MANUAL';
}
): Promise<ApplicationDetails> {
return fetchApi<ApplicationDetails>(`/applications/${id}`, {
return fetchApi<ApplicationDetails>(`/applications/${id}/force`, {
method: 'PUT',
body: JSON.stringify(updates),
});
@@ -94,7 +220,55 @@ export async function getApplicationHistory(id: string): Promise<ClassificationR
return fetchApi<ClassificationResult[]>(`/applications/${id}/history`);
}
// =============================================================================
// Cache Management
// =============================================================================
export interface CacheStatus {
cache: {
totalObjects: number;
objectsByType: Record<string, number>;
totalRelations: number;
lastFullSync: string | null;
lastIncrementalSync: string | null;
isWarm: boolean;
dbSizeBytes: number;
};
sync: {
isRunning: boolean;
isSyncing: boolean;
lastFullSync: string | null;
lastIncrementalSync: string | null;
nextIncrementalSync: string | null;
incrementalInterval: number;
};
supportedTypes: string[];
}
export async function getCacheStatus(): Promise<CacheStatus> {
return fetchApi<CacheStatus>('/cache/status');
}
export async function triggerSync(): Promise<{ status: string; message: string }> {
return fetchApi<{ status: string; message: string }>('/cache/sync', {
method: 'POST',
});
}
export async function triggerTypeSync(objectType: string): Promise<{
status: string;
objectType: string;
stats: { objectsProcessed: number; relationsExtracted: number; duration: number };
}> {
return fetchApi(`/cache/sync/${objectType}`, {
method: 'POST',
});
}
// =============================================================================
// AI Provider type
// =============================================================================
export type AIProvider = 'claude' | 'openai';
// AI Status response type
@@ -112,7 +286,10 @@ export interface AIStatusResponse {
};
}
// =============================================================================
// Classifications
// =============================================================================
export async function getAISuggestion(id: string, provider?: AIProvider): Promise<AISuggestion> {
const url = provider
? `/classifications/suggest/${id}?provider=${provider}`
@@ -144,7 +321,10 @@ export async function getAIPrompt(id: string): Promise<{ prompt: string }> {
return fetchApi(`/classifications/prompt/${id}`);
}
// =============================================================================
// Reference Data
// =============================================================================
export async function getReferenceData(): Promise<{
dynamicsFactors: ReferenceValue[];
complexityFactors: ReferenceValue[];
@@ -153,12 +333,14 @@ export async function getReferenceData(): Promise<{
organisations: ReferenceValue[];
hostingTypes: ReferenceValue[];
applicationFunctions: ReferenceValue[];
applicationClusters: ReferenceValue[];
applicationSubteams: ReferenceValue[];
applicationTeams: ReferenceValue[];
applicationTypes: ReferenceValue[];
businessImportance: ReferenceValue[];
businessImpactAnalyses: ReferenceValue[];
applicationManagementHosting: ReferenceValue[];
applicationManagementTAM: ReferenceValue[];
subteamToTeamMapping: Record<string, ReferenceValue | null>;
}> {
return fetchApi('/reference-data');
}
@@ -191,8 +373,8 @@ export async function getHostingTypes(): Promise<ReferenceValue[]> {
return fetchApi<ReferenceValue[]>('/reference-data/hosting-types');
}
export async function getApplicationClusters(): Promise<ReferenceValue[]> {
return fetchApi<ReferenceValue[]>('/reference-data/application-clusters');
export async function getApplicationSubteams(): Promise<ReferenceValue[]> {
return fetchApi<ReferenceValue[]>('/reference-data/application-subteams');
}
export async function getApplicationTypes(): Promise<ReferenceValue[]> {
@@ -211,12 +393,18 @@ export async function getBusinessImpactAnalyses(): Promise<ReferenceValue[]> {
return fetchApi<ReferenceValue[]>('/reference-data/business-impact-analyses');
}
// =============================================================================
// Config
// =============================================================================
export async function getConfig(): Promise<{ jiraHost: string }> {
return fetchApi<{ jiraHost: string }>('/config');
}
// =============================================================================
// Dashboard
// =============================================================================
export async function getDashboardStats(forceRefresh: boolean = false): Promise<DashboardStats> {
const params = forceRefresh ? '?refresh=true' : '';
return fetchApi<DashboardStats>(`/dashboard/stats${params}`);
@@ -226,7 +414,10 @@ export async function getRecentClassifications(limit: number = 10): Promise<Clas
return fetchApi<ClassificationResult[]>(`/dashboard/recent?limit=${limit}`);
}
// =============================================================================
// Team Dashboard
// =============================================================================
export async function getTeamDashboardData(excludedStatuses: ApplicationStatus[] = []): Promise<TeamDashboardData> {
const params = new URLSearchParams();
// Always send excludedStatuses parameter, even if empty, so backend knows the user's intent
@@ -235,7 +426,10 @@ export async function getTeamDashboardData(excludedStatuses: ApplicationStatus[]
return fetchApi<TeamDashboardData>(`/applications/team-dashboard?${queryString}`);
}
// =============================================================================
// Configuration
// =============================================================================
export interface EffortCalculationConfig {
governanceModelRules: Array<{
governanceModel: string;
@@ -365,7 +559,10 @@ export async function updateEffortCalculationConfigV25(config: EffortCalculation
});
}
// =============================================================================
// AI Chat
// =============================================================================
import type { ChatMessage, ChatResponse } from '../types';
export async function sendChatMessage(
@@ -389,3 +586,98 @@ export async function clearConversation(conversationId: string): Promise<{ succe
method: 'DELETE',
});
}
// =============================================================================
// CMDB Search
// =============================================================================
export interface CMDBSearchObjectType {
id: number;
name: string;
iconUrl: string;
}
export interface CMDBSearchResultAttribute {
id: number;
name: string;
objectTypeAttributeId: number;
values: string[];
}
export interface CMDBSearchResult {
id: number;
key: string;
label: string;
objectTypeId: number;
avatarUrl: string;
attributes: CMDBSearchResultAttribute[];
}
export interface CMDBSearchResponse {
metadata: {
count: number;
offset: number;
limit: number;
total: number;
criteria: { query: string; type: string; schema: number };
};
objectTypes: CMDBSearchObjectType[];
results: CMDBSearchResult[];
}
// CMDB free-text search
export async function searchCMDB(query: string, limit: number = 10000): Promise<CMDBSearchResponse> {
return fetchApi<CMDBSearchResponse>(`/search?query=${encodeURIComponent(query)}&limit=${limit}`);
}
// =============================================================================
// Schema / Data Model
// =============================================================================
export interface SchemaAttributeDefinition {
jiraId: number;
name: string;
fieldName: string;
type: 'text' | 'integer' | 'float' | 'boolean' | 'date' | 'datetime' | 'select' | 'reference' | 'url' | 'email' | 'textarea' | 'user' | 'status' | 'unknown';
isMultiple: boolean;
isEditable: boolean;
isRequired: boolean;
isSystem: boolean;
referenceTypeId?: number;
referenceTypeName?: string;
description?: string;
}
export interface SchemaObjectTypeDefinition {
jiraTypeId: number;
name: string;
typeName: string;
syncPriority: number;
objectCount: number;
attributes: SchemaAttributeDefinition[];
incomingLinks: Array<{
fromType: string;
fromTypeName: string;
attributeName: string;
isMultiple: boolean;
}>;
outgoingLinks: Array<{
toType: string;
toTypeName: string;
attributeName: string;
isMultiple: boolean;
}>;
}
export interface SchemaResponse {
metadata: {
generatedAt: string;
objectTypeCount: number;
totalAttributes: number;
};
objectTypes: Record<string, SchemaObjectTypeDefinition>;
}
export async function getSchema(): Promise<SchemaResponse> {
return fetchApi<SchemaResponse>('/schema');
}

View File

@@ -9,6 +9,9 @@ export interface User {
}
interface AuthConfig {
// The configured authentication method
authMethod: 'pat' | 'oauth' | 'none';
// Legacy fields (for backward compatibility)
oauthEnabled: boolean;
serviceAccountEnabled: boolean;
jiraHost: string;

View File

@@ -11,7 +11,7 @@ interface SearchState {
setGovernanceModel: (value: 'all' | 'filled' | 'empty') => void;
setDynamicsFactor: (value: 'all' | 'filled' | 'empty') => void;
setComplexityFactor: (value: 'all' | 'filled' | 'empty') => void;
setApplicationCluster: (value: 'all' | 'filled' | 'empty') => void;
setApplicationSubteam: (value: 'all' | 'filled' | 'empty' | string) => void;
setApplicationType: (value: 'all' | 'filled' | 'empty') => void;
setOrganisation: (value: string | undefined) => void;
setHostingType: (value: string | undefined) => void;
@@ -40,7 +40,7 @@ const defaultFilters: SearchFilters = {
governanceModel: 'all',
dynamicsFactor: 'all',
complexityFactor: 'all',
applicationCluster: 'all',
applicationSubteam: 'all',
applicationType: 'all',
organisation: undefined,
hostingType: undefined,
@@ -88,9 +88,9 @@ export const useSearchStore = create<SearchState>((set) => ({
currentPage: 1,
})),
setApplicationCluster: (value) =>
setApplicationSubteam: (value) =>
set((state) => ({
filters: { ...state.filters, applicationCluster: value },
filters: { ...state.filters, applicationSubteam: value },
currentPage: 1,
})),

View File

@@ -26,6 +26,7 @@ export interface ReferenceValue {
remarks?: string; // Remarks attribute for Governance Model
application?: string; // Application attribute for Governance Model
indicators?: string; // Indicators attribute for Business Impact Analyse
teamType?: string; // Type attribute for Team objects (Business, Enabling, Staf)
}
// Application list item (summary view)
@@ -38,7 +39,8 @@ export interface ApplicationListItem {
governanceModel: ReferenceValue | null;
dynamicsFactor: ReferenceValue | null;
complexityFactor: ReferenceValue | null;
applicationCluster: ReferenceValue | null;
applicationSubteam: ReferenceValue | null;
applicationTeam: ReferenceValue | null;
applicationType: ReferenceValue | null;
platform: ReferenceValue | null; // Reference to parent Platform Application Component
requiredEffortApplicationManagement: number | null; // Calculated field
@@ -74,7 +76,8 @@ export interface ApplicationDetails {
complexityFactor: ReferenceValue | null;
numberOfUsers: ReferenceValue | null;
governanceModel: ReferenceValue | null;
applicationCluster: ReferenceValue | null;
applicationSubteam: ReferenceValue | null;
applicationTeam: ReferenceValue | null;
applicationType: ReferenceValue | null;
platform: ReferenceValue | null; // Reference to parent Platform Application Component
requiredEffortApplicationManagement: number | null; // Calculated field
@@ -92,7 +95,7 @@ export interface SearchFilters {
governanceModel?: 'all' | 'filled' | 'empty';
dynamicsFactor?: 'all' | 'filled' | 'empty';
complexityFactor?: 'all' | 'filled' | 'empty';
applicationCluster?: 'all' | 'filled' | 'empty';
applicationSubteam?: 'all' | 'filled' | 'empty' | string; // Can be 'all', 'empty', or a specific subteam name
applicationType?: 'all' | 'filled' | 'empty';
organisation?: string;
hostingType?: string;
@@ -168,7 +171,8 @@ export interface PendingChanges {
complexityFactor?: { from: ReferenceValue | null; to: ReferenceValue };
numberOfUsers?: { from: ReferenceValue | null; to: ReferenceValue };
governanceModel?: { from: ReferenceValue | null; to: ReferenceValue };
applicationCluster?: { from: ReferenceValue | null; to: ReferenceValue };
applicationSubteam?: { from: ReferenceValue | null; to: ReferenceValue };
applicationTeam?: { from: ReferenceValue | null; to: ReferenceValue };
applicationType?: { from: ReferenceValue | null; to: ReferenceValue };
}
@@ -189,7 +193,8 @@ export interface ReferenceOptions {
numberOfUsers: ReferenceValue[];
governanceModels: ReferenceValue[];
applicationFunctions: ReferenceValue[];
applicationClusters: ReferenceValue[];
applicationSubteams: ReferenceValue[];
applicationTeams: ReferenceValue[];
applicationTypes: ReferenceValue[];
organisations: ReferenceValue[];
hostingTypes: ReferenceValue[];
@@ -220,9 +225,12 @@ export interface ZiraTaxonomy {
// Dashboard statistics
export interface DashboardStats {
totalApplications: number;
totalApplications: number; // Excluding Closed/Deprecated
totalAllApplications: number; // Including all statuses (for status distribution)
classifiedCount: number;
unclassifiedCount: number;
withApplicationFunction: number;
applicationFunctionPercentage: number;
byStatus: Record<string, number>;
byDomain: Record<string, number>;
byGovernanceModel: Record<string, number>;
@@ -284,8 +292,9 @@ export interface PlatformWithWorkloads {
totalEffort: number; // platformEffort + workloadsEffort
}
export interface TeamDashboardCluster {
cluster: ReferenceValue | null;
// Subteam level in team dashboard hierarchy
export interface TeamDashboardSubteam {
subteam: ReferenceValue | null;
applications: ApplicationListItem[]; // Regular applications (non-Platform, non-Workload)
platforms: PlatformWithWorkloads[]; // Platforms with their workloads
totalEffort: number; // Sum of all applications + platforms + workloads
@@ -295,17 +304,21 @@ export interface TeamDashboardCluster {
byGovernanceModel: Record<string, number>; // Distribution per governance model
}
// Team level in team dashboard hierarchy (contains subteams)
export interface TeamDashboardTeam {
team: ReferenceValue | null; // team.teamType contains "Business", "Enabling", or "Staf"
subteams: TeamDashboardSubteam[];
// Aggregated KPIs (sum of all subteams)
totalEffort: number;
minEffort: number;
maxEffort: number;
applicationCount: number;
byGovernanceModel: Record<string, number>;
}
export interface TeamDashboardData {
clusters: TeamDashboardCluster[];
unassigned: {
applications: ApplicationListItem[]; // Regular applications (non-Platform, non-Workload)
platforms: PlatformWithWorkloads[]; // Platforms with their workloads
totalEffort: number; // Sum of all applications + platforms + workloads
minEffort: number; // Sum of all minimum FTE values
maxEffort: number; // Sum of all maximum FTE values
applicationCount: number; // Count of all applications (including platforms and workloads)
byGovernanceModel: Record<string, number>; // Distribution per governance model
};
teams: TeamDashboardTeam[];
unassigned: TeamDashboardSubteam; // Apps without team assignment
}
// Chat message for AI conversation