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:
@@ -2,9 +2,9 @@
|
||||
<html lang="nl">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href="/logo-zuyderland.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ZiRA Classificatie Tool - Zuyderland</title>
|
||||
<title>CMDB Analyse Tool - Zuyderland</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
1
frontend/public/logo-zuyderland.svg
Normal file
1
frontend/public/logo-zuyderland.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 600"><defs><style>.cls-1{fill:#7bcaef;}.cls-2{fill:#3b6d89;}</style></defs><g id="Laag_2" data-name="Laag 2"><g id="Logo_beeldmerk_A" data-name="Logo beeldmerk A"><g id="zl-beeldmerk-a"><polygon id="Binnenkant" class="cls-1" points="400 200 400 0 200 0 200 200 0 400 200 400 200 600 400 600 400 400 600 200 400 200"/><g id="Buitenkant"><path class="cls-2" d="M200,200H80A80,80,0,0,1,0,120V80A80,80,0,0,1,80,0H200Z"/><path class="cls-2" d="M600,200H400V0H520a80,80,0,0,1,80,80Z"/><path class="cls-2" d="M200,600H80A80,80,0,0,1,0,520V400H200Z"/><path class="cls-2" d="M520,600H400V400H520a80,80,0,0,1,80,80v40A80,80,0,0,1,520,600Z"/></g></g></g></g></svg>
|
||||
|
After Width: | Height: | Size: 711 B |
@@ -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 />;
|
||||
}
|
||||
|
||||
|
||||
620
frontend/src/components/ApplicationInfo.tsx
Normal file
620
frontend/src/components/ApplicationInfo.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
180
frontend/src/components/CacheStatusIndicator.tsx
Normal file
180
frontend/src/components/CacheStatusIndicator.tsx
Normal 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;
|
||||
|
||||
162
frontend/src/components/ConflictDialog.tsx
Normal file
162
frontend/src/components/ConflictDialog.tsx
Normal 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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
648
frontend/src/components/DataModelDashboard.tsx
Normal file
648
frontend/src/components/DataModelDashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
258
frontend/src/components/EffortDisplay.tsx
Normal file
258
frontend/src/components/EffortDisplay.tsx
Normal 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;
|
||||
|
||||
337
frontend/src/components/FTECalculator.tsx
Normal file
337
frontend/src/components/FTECalculator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
303
frontend/src/components/GovernanceAnalysis.tsx
Normal file
303
frontend/src/components/GovernanceAnalysis.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
)}
|
||||
|
||||
165
frontend/src/components/ReportsDashboard.tsx
Normal file
165
frontend/src/components/ReportsDashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
548
frontend/src/components/SearchDashboard.tsx
Normal file
548
frontend/src/components/SearchDashboard.tsx
Normal 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(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/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
212
frontend/src/hooks/useEffortCalculation.ts
Normal file
212
frontend/src/hooks/useEffortCalculation.ts
Normal 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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user