UI styling improvements: dashboard headers and navigation

- Restore blue PageHeader on Dashboard (/app-components)
- Update homepage (/) with subtle header design without blue bar
- Add uniform PageHeader styling to application edit page
- Fix Rapporten link on homepage to point to /reports overview
- Improve header descriptions spacing for better readability
This commit is contained in:
2026-01-21 03:24:56 +01:00
parent e276e77fbc
commit cdee0e8819
138 changed files with 24551 additions and 3352 deletions

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/logo-zuyderland.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CMDB Analyse Tool - Zuyderland</title>
<title>CMDB Insight - Zuyderland</title>
</head>
<body>
<div id="root"></div>

View File

@@ -1,5 +1,5 @@
{
"name": "zira-frontend",
"name": "cmdb-insight-frontend",
"version": "1.0.0",
"type": "module",
"scripts": {

View File

@@ -10,7 +10,6 @@ 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 TechnicalDebtHeatmap from './components/TechnicalDebtHeatmap';
import LifecyclePipeline from './components/LifecyclePipeline';
import DataCompletenessScore from './components/DataCompletenessScore';
@@ -21,6 +20,9 @@ import FTECalculator from './components/FTECalculator';
import DataCompletenessConfig from './components/DataCompletenessConfig';
import BIASyncDashboard from './components/BIASyncDashboard';
import BusinessImportanceComparison from './components/BusinessImportanceComparison';
import DataValidationDashboard from './components/DataValidationDashboard';
import SchemaConfigurationSettings from './components/SchemaConfigurationSettings';
import ArchitectureDebugPage from './components/ArchitectureDebugPage';
import Login from './components/Login';
import ForgotPassword from './components/ForgotPassword';
import ResetPassword from './components/ResetPassword';
@@ -29,6 +31,7 @@ import ProtectedRoute from './components/ProtectedRoute';
import UserManagement from './components/UserManagement';
import RoleManagement from './components/RoleManagement';
import ProfileSettings from './components/ProfileSettings';
import { ToastContainerComponent } from './components/Toast';
import { useAuthStore } from './stores/authStore';
// Module-level singleton to prevent duplicate initialization across StrictMode remounts
@@ -237,14 +240,15 @@ function AppContent() {
const hasPermission = useAuthStore((state) => state.hasPermission);
const config = useAuthStore((state) => state.config);
// Navigation structure
// Navigation structure - Logical flow for CMDB setup and data management
// Flow: 1. Setup (Schema Discovery → Configuration → Sync) → 2. Data (Model → Validation) → 3. Application Component → 4. Reports
const appComponentsDropdown: NavDropdown = {
label: 'Application Component',
basePath: '/application',
items: [
{ path: '/app-components', label: 'Dashboard', exact: true, requiredPermission: 'search' },
{ path: '/application/overview', label: 'Overzicht', exact: false, requiredPermission: 'search' },
{ path: '/application/fte-calculator', label: 'FTE Calculator', exact: true, requiredPermission: 'search' },
],
};
@@ -270,6 +274,7 @@ function AppContent() {
basePath: '/apps',
items: [
{ path: '/apps/bia-sync', label: 'BIA Sync', exact: true, requiredPermission: 'search' },
{ path: '/apps/fte-calculator', label: 'FTE Calculator', exact: true, requiredPermission: 'search' },
],
};
@@ -277,9 +282,8 @@ function AppContent() {
label: 'Instellingen',
basePath: '/settings',
items: [
{ path: '/settings/fte-config', label: 'FTE Config', exact: true, requiredPermission: 'manage_settings' },
{ path: '/settings/data-model', label: 'Datamodel', exact: true, requiredPermission: 'manage_settings' },
{ path: '/settings/data-completeness-config', label: 'Data Completeness Config', exact: true, requiredPermission: 'manage_settings' },
{ path: '/settings/fte-config', label: 'FTE Config', exact: true, requiredPermission: 'manage_settings' },
],
};
@@ -287,16 +291,23 @@ function AppContent() {
label: 'Beheer',
basePath: '/admin',
items: [
{ path: '/settings/schema-configuration', label: 'Schema Configuratie & Datamodel', exact: true, requiredPermission: 'manage_settings' },
{ path: '/settings/data-validation', label: 'Data Validatie', exact: true, requiredPermission: 'manage_settings' },
{ path: '/admin/users', label: 'Gebruikers', exact: true, requiredPermission: 'manage_users' },
{ path: '/admin/roles', label: 'Rollen', exact: true, requiredPermission: 'manage_roles' },
{ path: '/admin/debug', label: 'Architecture Debug', exact: true, requiredPermission: 'admin' },
],
};
const isAppComponentsActive = location.pathname.startsWith('/app-components') || location.pathname.startsWith('/application');
const isReportsActive = location.pathname.startsWith('/reports');
const isSettingsActive = location.pathname.startsWith('/settings');
// Settings is active for /settings paths EXCEPT admin items (schema-configuration, data-model, data-validation)
const isSettingsActive = location.pathname.startsWith('/settings')
&& !location.pathname.startsWith('/settings/schema-configuration')
&& !location.pathname.startsWith('/settings/data-model')
&& !location.pathname.startsWith('/settings/data-validation');
const isAppsActive = location.pathname.startsWith('/apps');
const isAdminActive = location.pathname.startsWith('/admin');
const isAdminActive = location.pathname.startsWith('/admin') || location.pathname.startsWith('/settings/schema-configuration') || location.pathname.startsWith('/settings/data-model') || location.pathname.startsWith('/settings/data-validation');
return (
<div className="min-h-screen bg-white">
@@ -319,16 +330,16 @@ function AppContent() {
{/* Application Component Dropdown */}
<NavDropdown dropdown={appComponentsDropdown} isActive={isAppComponentsActive} hasPermission={hasPermission} />
{/* Apps Dropdown */}
<NavDropdown dropdown={appsDropdown} isActive={isAppsActive} hasPermission={hasPermission} />
{/* Reports Dropdown */}
<NavDropdown dropdown={reportsDropdown} isActive={isReportsActive} hasPermission={hasPermission} />
{/* Settings Dropdown */}
{/* Apps Dropdown */}
<NavDropdown dropdown={appsDropdown} isActive={isAppsActive} hasPermission={hasPermission} />
{/* Settings Dropdown - Advanced configuration */}
<NavDropdown dropdown={settingsDropdown} isActive={isSettingsActive} hasPermission={hasPermission} />
{/* Admin Dropdown */}
{/* Admin Dropdown - Setup (Schema Config + Data Model + Data Validation) + Administration */}
<NavDropdown dropdown={adminDropdown} isActive={isAdminActive} hasPermission={hasPermission} />
</nav>
</div>
@@ -338,6 +349,9 @@ function AppContent() {
</div>
</header>
{/* Toast Notifications */}
<ToastContainerComponent />
{/* Main content */}
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<Routes>
@@ -346,7 +360,6 @@ function AppContent() {
{/* Application routes (new structure) - specific routes first, then dynamic */}
<Route path="/application/overview" element={<ProtectedRoute requirePermission="search"><ApplicationList /></ProtectedRoute>} />
<Route path="/application/fte-calculator" element={<ProtectedRoute requirePermission="search"><FTECalculator /></ProtectedRoute>} />
<Route path="/application/:id/edit" element={<ProtectedRoute requirePermission="edit_applications"><GovernanceModelHelper /></ProtectedRoute>} />
<Route path="/application/:id" element={<ProtectedRoute requirePermission="search"><ApplicationInfo /></ProtectedRoute>} />
@@ -367,10 +380,13 @@ function AppContent() {
{/* Apps routes */}
<Route path="/apps/bia-sync" element={<ProtectedRoute requirePermission="search"><BIASyncDashboard /></ProtectedRoute>} />
<Route path="/apps/fte-calculator" element={<ProtectedRoute requirePermission="search"><FTECalculator /></ProtectedRoute>} />
{/* Settings routes */}
<Route path="/settings/schema-configuration" element={<ProtectedRoute requirePermission="manage_settings"><SchemaConfigurationSettings /></ProtectedRoute>} />
<Route path="/settings/fte-config" element={<ProtectedRoute requirePermission="manage_settings"><ConfigurationV25 /></ProtectedRoute>} />
<Route path="/settings/data-model" element={<ProtectedRoute requirePermission="manage_settings"><DataModelDashboard /></ProtectedRoute>} />
<Route path="/settings/data-model" element={<Navigate to="/settings/schema-configuration" replace />} />
<Route path="/settings/data-validation" element={<ProtectedRoute requirePermission="manage_settings"><DataValidationDashboard /></ProtectedRoute>} />
<Route path="/settings/data-completeness-config" element={<ProtectedRoute requirePermission="manage_settings"><DataCompletenessConfig /></ProtectedRoute>} />
<Route path="/settings/profile" element={<ProtectedRoute><ProfileSettings /></ProtectedRoute>} />
{/* Legacy redirects for old routes */}
@@ -379,6 +395,7 @@ function AppContent() {
{/* Admin routes */}
<Route path="/admin/users" element={<ProtectedRoute requirePermission="manage_users"><UserManagement /></ProtectedRoute>} />
<Route path="/admin/roles" element={<ProtectedRoute requirePermission="manage_roles"><RoleManagement /></ProtectedRoute>} />
<Route path="/admin/debug" element={<ProtectedRoute requirePermission="admin"><ArchitectureDebugPage /></ProtectedRoute>} />
{/* Legacy redirects for bookmarks - redirect old paths to new ones */}
<Route path="/app-components/overview" element={<Navigate to="/application/overview" replace />} />
@@ -386,7 +403,8 @@ function AppContent() {
<Route path="/app-components/fte-config" element={<Navigate to="/settings/fte-config" replace />} />
<Route path="/applications" element={<Navigate to="/application/overview" replace />} />
<Route path="/applications/:id" element={<RedirectToApplicationEdit />} />
<Route path="/reports/data-model" element={<Navigate to="/settings/data-model" replace />} />
<Route path="/application/fte-calculator" element={<Navigate to="/apps/fte-calculator" replace />} />
<Route path="/reports/data-model" element={<Navigate to="/settings/schema-configuration" replace />} />
<Route path="/reports/bia-sync" element={<Navigate to="/apps/bia-sync" replace />} />
<Route path="/teams" element={<ProtectedRoute requirePermission="view_reports"><TeamDashboard /></ProtectedRoute>} />
<Route path="/configuration" element={<Navigate to="/settings/fte-config" replace />} />

View File

@@ -5,6 +5,7 @@ import {
getApplicationById,
getConfig,
getRelatedObjects,
refreshApplication,
RelatedObject,
} from '../services/api';
import { StatusBadge, BusinessImportanceBadge } from './ApplicationList';
@@ -133,6 +134,8 @@ export default function ApplicationInfo() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [jiraHost, setJiraHost] = useState<string>('');
const [refreshing, setRefreshing] = useState(false);
const [refreshMessage, setRefreshMessage] = useState<string | null>(null);
// Use centralized effort calculation hook
const { calculatedFte, breakdown: effortBreakdown } = useEffortCalculation({
@@ -244,6 +247,44 @@ export default function ApplicationInfo() {
setGovernanceExpanded(prev => !prev);
};
const handleRefresh = async () => {
if (!id || refreshing) return;
setRefreshing(true);
setRefreshMessage(null);
try {
const result = await refreshApplication(id);
setRefreshMessage('Applicatie succesvol gesynchroniseerd vanuit Jira');
// Reload the application data after a short delay to show the success message
setTimeout(async () => {
try {
const refreshedApp = await getApplicationById(id);
setApplication(refreshedApp);
// Clear success message after 3 seconds
setTimeout(() => {
setRefreshMessage(null);
}, 3000);
} catch (err) {
setRefreshMessage('Applicatie gesynchroniseerd, maar herladen mislukt. Ververs de pagina.');
// Clear error message after 5 seconds
setTimeout(() => {
setRefreshMessage(null);
}, 5000);
}
}, 1000);
} catch (err) {
setRefreshMessage(err instanceof Error ? err.message : 'Synchronisatie mislukt');
// Clear error message after 5 seconds
setTimeout(() => {
setRefreshMessage(null);
}, 5000);
} finally {
setRefreshing(false);
}
};
if (loading) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50/30 to-slate-50 flex items-center justify-center">
@@ -391,6 +432,24 @@ export default function ApplicationInfo() {
</a>
</Tooltip>
)}
<Tooltip text="Synchroniseer applicatie vanuit Jira">
<button
onClick={handleRefresh}
disabled={refreshing}
className="inline-flex items-center justify-center w-10 h-10 bg-white hover:bg-gray-50 text-blue-600 rounded-lg transition-all shadow-sm hover:shadow-md disabled:opacity-50 disabled:cursor-not-allowed"
>
{refreshing ? (
<svg className="w-5 h-5 animate-spin" 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="w-5 h-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>
)}
</button>
</Tooltip>
</div>
</div>
</div>
@@ -402,6 +461,34 @@ export default function ApplicationInfo() {
</div>
</div>
{/* Refresh message */}
{refreshMessage && (
<div className={`px-6 lg:px-8 py-3 border-b ${
refreshMessage.includes('succesvol') || refreshMessage.includes('gesynchroniseerd')
? 'bg-green-50 border-green-200'
: 'bg-yellow-50 border-yellow-200'
}`}>
<div className="flex items-center gap-2 text-sm">
{refreshMessage.includes('succesvol') || refreshMessage.includes('gesynchroniseerd') ? (
<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 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
) : (
<svg className="w-5 h-5 text-yellow-600" 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 className={
refreshMessage.includes('succesvol') || refreshMessage.includes('gesynchroniseerd')
? 'text-green-800'
: 'text-yellow-800'
}>
{refreshMessage}
</span>
</div>
</div>
)}
{/* Reference warning - only show if reference is truly empty */}
{(() => {
const refValue = application.reference;

View File

@@ -1,10 +1,11 @@
import { useEffect, useState, useCallback } from 'react';
import { useNavigate, useSearchParams, Link } from 'react-router-dom';
import { clsx } from 'clsx';
import { searchApplications, getReferenceData } from '../services/api';
import { searchApplications, getReferenceData, triggerSync } from '../services/api';
import { useSearchStore } from '../stores/searchStore';
import { useNavigationStore } from '../stores/navigationStore';
import type { ApplicationListItem, SearchResult, ReferenceValue, ApplicationStatus } from '../types';
import PageHeader from './PageHeader';
const ALL_STATUSES: ApplicationStatus[] = [
'In Production',
@@ -47,6 +48,7 @@ export default function ApplicationList() {
const [businessImportanceOptions, setBusinessImportanceOptions] = useState<ReferenceValue[]>([]);
const [applicationSubteams, setApplicationSubteams] = useState<ReferenceValue[]>([]);
const [showFilters, setShowFilters] = useState(true);
const [syncing, setSyncing] = useState(false);
// Sync URL params with store on mount
useEffect(() => {
@@ -96,12 +98,23 @@ export default function ApplicationList() {
async function loadReferenceData() {
try {
const data = await getReferenceData();
setOrganisations(data.organisations);
setHostingTypes(data.hostingTypes);
console.log('Loaded reference data:', {
organisations: data.organisations?.length || 0,
hostingTypes: data.hostingTypes?.length || 0,
businessImportance: data.businessImportance?.length || 0,
applicationSubteams: data.applicationSubteams?.length || 0,
});
setOrganisations(data.organisations || []);
setHostingTypes(data.hostingTypes || []);
setBusinessImportanceOptions(data.businessImportance || []);
setApplicationSubteams(data.applicationSubteams || []);
} catch (err) {
console.error('Failed to load reference data', err);
// Set empty arrays on error so UI doesn't break
setOrganisations([]);
setHostingTypes([]);
setBusinessImportanceOptions([]);
setApplicationSubteams([]);
}
}
loadReferenceData();
@@ -141,21 +154,58 @@ export default function ApplicationList() {
}
};
const handleSync = async () => {
if (syncing) return;
setSyncing(true);
setError(null);
try {
await triggerSync();
// Start polling for results - sync runs in background
const pollInterval = setInterval(async () => {
try {
await fetchApplications();
// Stop polling if we have results
if (result && result.totalCount > 0) {
clearInterval(pollInterval);
setSyncing(false);
}
} catch (err) {
console.error('Failed to poll for results', err);
}
}, 3000); // Poll every 3 seconds
// Stop polling after 5 minutes max
setTimeout(() => {
clearInterval(pollInterval);
setSyncing(false);
}, 5 * 60 * 1000);
} catch (err) {
console.error('Failed to trigger sync', err);
setError('Synchronisatie mislukt. Controleer de server logs.');
setSyncing(false);
}
};
return (
<div className="space-y-6">
{/* Page header */}
<div className="flex justify-between items-center">
<div>
<h2 className="text-2xl font-bold text-gray-900">Applicaties</h2>
<p className="text-gray-600">Zoek en classificeer applicatiecomponenten</p>
</div>
<button
onClick={() => setShowFilters(!showFilters)}
className="btn btn-secondary"
>
{showFilters ? 'Verberg filters' : 'Toon filters'}
</button>
</div>
<PageHeader
title="Applicaties"
description="Zoek en classificeer applicatiecomponenten"
icon={
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
}
actions={
<button
onClick={() => setShowFilters(!showFilters)}
className="px-4 py-2 bg-white/20 hover:bg-white/30 backdrop-blur-sm rounded-lg transition-colors flex items-center gap-2 text-sm font-medium"
>
{showFilters ? 'Verberg filters' : 'Toon filters'}
</button>
}
/>
{/* Search and filters */}
<div className="card">
@@ -288,13 +338,18 @@ export default function ApplicationList() {
value={filters.organisation || ''}
onChange={(e) => setOrganisation(e.target.value || undefined)}
className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
disabled={organisations.length === 0 && loading}
>
<option value="">Alle organisaties</option>
{organisations.map((org) => (
<option key={org.objectId} value={org.name}>
{org.name}
</option>
))}
{organisations.length === 0 && !loading ? (
<option value="" disabled>Geen organisaties beschikbaar (cache leeg)</option>
) : (
organisations.map((org) => (
<option key={org.objectId} value={org.name}>
{org.name}
</option>
))
)}
</select>
</div>
@@ -304,13 +359,18 @@ export default function ApplicationList() {
value={filters.hostingType || ''}
onChange={(e) => setHostingType(e.target.value || undefined)}
className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
disabled={hostingTypes.length === 0 && loading}
>
<option value="">Alle types</option>
{hostingTypes.map((type) => (
<option key={type.objectId} value={type.name}>
{type.name}
</option>
))}
{hostingTypes.length === 0 && !loading ? (
<option value="" disabled>Geen hosting types beschikbaar (cache leeg)</option>
) : (
hostingTypes.map((type) => (
<option key={type.objectId} value={type.name}>
{type.name}
</option>
))
)}
</select>
</div>
@@ -320,13 +380,18 @@ export default function ApplicationList() {
value={filters.businessImportance || ''}
onChange={(e) => setBusinessImportance(e.target.value || undefined)}
className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
disabled={businessImportanceOptions.length === 0 && loading}
>
<option value="">Alle</option>
{businessImportanceOptions.map((importance) => (
<option key={importance.objectId} value={importance.name}>
{importance.name}
</option>
))}
{businessImportanceOptions.length === 0 && !loading ? (
<option value="" disabled>Geen business importance beschikbaar (cache leeg)</option>
) : (
businessImportanceOptions.map((importance) => (
<option key={importance.objectId} value={importance.name}>
{importance.name}
</option>
))
)}
</select>
</div>
@@ -400,7 +465,50 @@ export default function ApplicationList() {
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{result?.applications.map((app, index) => (
{result && result.applications.length === 0 ? (
<tr>
<td colSpan={7} className="px-4 py-12 text-center">
<div className="flex flex-col items-center gap-4">
<svg className="w-12 h-12 text-gray-400" 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>
<div>
<p className="text-sm font-medium text-gray-900">Geen resultaten gevonden</p>
<p className="text-sm text-gray-500 mt-1">
{result.totalCount === 0
? 'De cache is leeg. Voer een volledige synchronisatie uit om applicaties te laden.'
: 'Probeer andere zoektermen of filters.'}
</p>
</div>
{result.totalCount === 0 && (
<button
onClick={handleSync}
disabled={syncing}
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed text-sm font-medium"
>
{syncing ? (
<>
<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>
Start volledige synchronisatie
</>
)}
</button>
)}
</div>
</td>
</tr>
) : (
result?.applications.map((app, index) => (
<tr
key={app.id}
className="hover:bg-blue-50 transition-colors group"
@@ -524,7 +632,8 @@ export default function ApplicationList() {
</Link>
</td>
</tr>
))}
))
)}
</tbody>
</table>
</div>

View File

@@ -0,0 +1,876 @@
/**
* ArchitectureDebugPage - Debug page for testing refactored architecture
*
* Tests:
* A - Level2 recursion: referencedObject with attributes is stored
* B - Disabled type: NOT full sync, but reference-only caching works
* C - Attribute wipe rule: shallow referencedObject doesn't delete existing values
* D - QueryService reconstruction: DB → TS object is correct
* E - Write-through: update to Jira + immediate DB update
*/
import { useState, useEffect } from 'react';
import { getConfiguredObjectTypes } from '../services/api';
import PageHeader from './PageHeader';
interface TestResult {
test: string;
status: 'pending' | 'running' | 'success' | 'error';
message?: string;
data?: any;
sqlQueries?: Array<{ sql: string; result: any }>;
timestamp?: string;
}
export default function ArchitectureDebugPage() {
const [testResults, setTestResults] = useState<Record<string, TestResult>>({});
const [loading, setLoading] = useState<Record<string, boolean>>({});
const [enabledTypes, setEnabledTypes] = useState<string[]>([]);
const [loadingEnabledTypes, setLoadingEnabledTypes] = useState(true);
const [inputs, setInputs] = useState({
typeName: 'ApplicationComponent',
objectKey: '',
referencedObjectKey: '',
disabledTypeName: 'HostingType',
updateField: 'description',
updateValue: 'Updated via Debug Page',
});
// Load enabled object types on mount
useEffect(() => {
const loadEnabledTypes = async () => {
try {
setLoadingEnabledTypes(true);
const config = await getConfiguredObjectTypes();
const enabled = config.schemas.flatMap(s =>
s.objectTypes.filter(ot => ot.enabled).map(ot => ot.objectTypeName)
);
setEnabledTypes(enabled);
} catch (error) {
console.error('Failed to load enabled types:', error);
} finally {
setLoadingEnabledTypes(false);
}
};
loadEnabledTypes();
}, []);
const updateTestResult = (testId: string, updates: Partial<TestResult>) => {
setTestResults(prev => ({
...prev,
[testId]: {
...prev[testId],
...updates,
timestamp: new Date().toISOString(),
} as TestResult,
}));
};
const executeSqlQuery = async (sql: string, params: any[] = []): Promise<any> => {
try {
const response = await fetch('/api/v2/debug/query', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({ sql, params }),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(errorData.error || `HTTP ${response.status}`);
}
const data = await response.json();
return data.result || [];
} catch (error) {
console.error('SQL query failed:', error);
throw error;
}
};
// Test A: Level2 recursion
const runTestA = async () => {
const testId = 'testA';
setLoading(prev => ({ ...prev, [testId]: true }));
updateTestResult(testId, { test: 'Test A: Level2 Recursion', status: 'running' });
try {
// Step 1: Sync object type
updateTestResult(testId, { message: `Syncing object type "${inputs.typeName}"...` });
const syncResponse = await fetch(`/api/v2/sync/objects/${inputs.typeName}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
});
if (!syncResponse.ok) {
const errorData = await syncResponse.json().catch(() => ({ error: `HTTP ${syncResponse.status}: Sync failed` }));
const errorMessage = errorData.error || `HTTP ${syncResponse.status}: Sync failed`;
// Extract enabled types from error details if available
let enabledTypesHint = '';
if (errorData.details?.enabledTypeNames && errorData.details.enabledTypeNames.length > 0) {
enabledTypesHint = ` Currently enabled: ${errorData.details.enabledTypeNames.join(', ')}.`;
}
// Provide helpful hints for common errors
let hint = '';
if (errorMessage.includes('not enabled')) {
hint = ` Tip: Go to Schema Configuration settings and enable "${inputs.typeName}".${enabledTypesHint}`;
} else if (errorMessage.includes('token')) {
hint = ' Tip: Check if JIRA_SERVICE_ACCOUNT_TOKEN is configured in .env or if you have a Personal Access Token configured.';
} else if (errorMessage.includes('404') || errorMessage.includes('not found')) {
hint = ' Tip: The object type may not exist in your Jira Assets instance. Check the type name spelling.';
}
throw new Error(errorMessage + hint);
}
const syncResult = await syncResponse.json();
// Check for errors in sync result
if (syncResult.hasErrors && syncResult.errors && syncResult.errors.length > 0) {
const errorMessages = syncResult.errors.map((e: any) => `${e.objectId}: ${e.error}`).join('; ');
console.warn(`Sync completed with errors: ${errorMessages}`);
}
// Check if sync had errors (even if not marked as hasErrors)
if (syncResult.errors && syncResult.errors.length > 0) {
const errorMessages = syncResult.errors.map((e: any) => e.error || e.message || 'Unknown error').join('; ');
// Check if it's a network/Jira API error
if (errorMessages.includes('Failed to fetch') || errorMessages.includes('fetch failed') || errorMessages.includes('network')) {
throw new Error(`Network error while syncing: ${errorMessages}. This usually means: - Jira host URL is incorrect or unreachable - Network connectivity issues - Jira API authentication failed. Check your Jira PAT token in Profile Settings and verify the JIRA_HOST environment variable.`);
}
throw new Error(`Sync failed: ${errorMessages}. Check backend logs for details.`);
}
if (syncResult.objectsCached === 0 && syncResult.objectsProcessed === 0) {
throw new Error('Sync completed but no objects were processed. This could mean:\n- The object type has no objects in Jira Assets\n- The IQL search returned no results\n- Check if the object type name is correct');
}
// Step 2: Get a sample object to check
updateTestResult(testId, { message: 'Fetching sample objects...' });
const objectsResponse = await fetch(`/api/v2/objects/${inputs.typeName}?limit=10`, {
credentials: 'include',
});
if (!objectsResponse.ok) {
const errorData = await objectsResponse.json().catch(() => ({ error: `HTTP ${objectsResponse.status}: Failed to fetch objects` }));
const errorMessage = errorData.error || `HTTP ${objectsResponse.status}: Failed to fetch objects`;
throw new Error(`${errorMessage}. Sync processed ${syncResult.objectsProcessed || 0} objects. If objects were synced, they may not be queryable yet.`);
}
const objectsData = await objectsResponse.json();
const sampleObject = objectsData.objects?.[0];
if (!sampleObject) {
throw new Error(`No objects found after sync. Sync processed ${syncResult.objectsProcessed || 0} objects, cached ${syncResult.objectsCached || 0}. The objects may not be queryable yet or the object type has no objects in Jira Assets.`);
}
// Step 3: Find a referenced object from the sample
updateTestResult(testId, { message: 'Looking for referenced objects in sample...' });
const referencedObjectKey = inputs.referencedObjectKey || (() => {
// Try to find a reference in the object
const references: string[] = [];
for (const [key, value] of Object.entries(sampleObject)) {
if (value && typeof value === 'object' && ('objectKey' in value || 'key' in value)) {
const objKey = (value as any).objectKey || (value as any).key;
if (objKey) references.push(objKey);
}
if (Array.isArray(value) && value.length > 0) {
for (const item of value) {
if (item && typeof item === 'object' && ('objectKey' in item || 'key' in item)) {
const objKey = item.objectKey || item.key;
if (objKey) references.push(objKey);
}
}
}
}
return references.length > 0 ? references[0] : null;
})();
if (!referencedObjectKey) {
const sampleObjectKeys = Object.keys(sampleObject).join(', ');
throw new Error(`No referenced object found in sample object. Object fields: ${sampleObjectKeys}. Please provide a referencedObjectKey manually if the object has references.`);
}
updateTestResult(testId, { message: `Found referenced object: ${referencedObjectKey}` });
// Step 4: Run SQL checks
updateTestResult(testId, { message: 'Running database checks...' });
const sqlQueries: Array<{ sql: string; result: any }> = [];
// Check 1: Both objects exist
const sampleObjectKey = sampleObject.objectKey || sampleObject.key || sampleObject.id;
if (!sampleObjectKey) {
throw new Error(`Sample object has no objectKey. Object structure: ${JSON.stringify(sampleObject)}`);
}
const check1Sql = `SELECT id, object_key, object_type_name, label FROM objects WHERE object_key IN (?, ?)`;
const check1Result = await executeSqlQuery(check1Sql, [sampleObjectKey, referencedObjectKey]);
sqlQueries.push({ sql: check1Sql, result: check1Result });
// Check 2: Referenced object has attribute values
// Handle both snake_case and camelCase column names
const refObj = check1Result.find((o: any) =>
(o.object_key || o.objectKey) === referencedObjectKey
);
if (!refObj) {
const foundKeys = check1Result.map((o: any) => (o.object_key || o.objectKey)).filter(Boolean);
throw new Error(`Referenced object "${referencedObjectKey}" not found in database. Found objects: ${foundKeys.join(', ') || 'none'}. The referenced object may not have been cached during sync.`);
}
const check2Sql = `SELECT COUNT(*) as count FROM attribute_values WHERE object_id = ?`;
const check2Result = await executeSqlQuery(check2Sql, [refObj.id]);
sqlQueries.push({ sql: check2Sql, result: check2Result });
const attrCount = check2Result[0]?.count || (check2Result[0]?.count === 0 ? 0 : null);
// Check 3: Get sample attribute values to verify they exist
const check3Sql = `SELECT av.*, a.field_name, a.attr_type
FROM attribute_values av
JOIN attributes a ON a.id = av.attribute_id
WHERE av.object_id = ?
LIMIT 5`;
const check3Result = await executeSqlQuery(check3Sql, [refObj.id]);
sqlQueries.push({ sql: check3Sql, result: check3Result });
updateTestResult(testId, {
status: 'success',
message: `✅ Sync completed successfully. Found ${syncResult.objectsCached || 0} objects. Referenced object "${referencedObjectKey}" exists with ${attrCount || 0} attribute values.`,
data: {
syncResult: {
objectsProcessed: syncResult.objectsProcessed,
objectsCached: syncResult.objectsCached,
relationsExtracted: syncResult.relationsExtracted,
},
sampleObject: { objectKey: sampleObjectKey, label: sampleObject.label || sampleObject.name },
referencedObject: {
objectKey: referencedObjectKey,
id: refObj.id,
typeName: refObj.object_type_name || refObj.objectTypeName,
label: refObj.label,
attributeValueCount: attrCount,
},
},
sqlQueries,
});
} catch (error) {
updateTestResult(testId, {
status: 'error',
message: error instanceof Error ? error.message : 'Test failed',
});
} finally {
setLoading(prev => ({ ...prev, [testId]: false }));
}
};
// Test B: Disabled type reference-only caching
const runTestB = async () => {
const testId = 'testB';
setLoading(prev => ({ ...prev, [testId]: true }));
updateTestResult(testId, { test: 'Test B: Disabled Type Reference-Only', status: 'running' });
try {
const disabledType = inputs.disabledTypeName;
// Step 1: Check initial count
updateTestResult(testId, { message: 'Checking initial object count...' });
const initialCountResult = await executeSqlQuery(
`SELECT COUNT(*) as count FROM objects WHERE object_type_name = ?`,
[disabledType]
);
const initialCount = initialCountResult[0]?.count || 0;
// Step 2: Sync an enabled type that references the disabled type
updateTestResult(testId, { message: `Syncing ${inputs.typeName} (should cache ${disabledType} references)...` });
const syncResponse = await fetch(`/api/v2/sync/objects/${inputs.typeName}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
});
if (!syncResponse.ok) throw new Error('Sync failed');
const syncResult = await syncResponse.json();
// Step 3: Check final count (should not have increased much)
updateTestResult(testId, { message: 'Checking final object count...' });
const finalCountResult = await executeSqlQuery(
`SELECT COUNT(*) as count FROM objects WHERE object_type_name = ?`,
[disabledType]
);
const finalCount = finalCountResult[0]?.count || 0;
// Step 4: Check relations
const relationsResult = await executeSqlQuery(
`SELECT COUNT(*) as count
FROM object_relations r
JOIN objects t ON t.id = r.target_id
WHERE t.object_type_name = ?`,
[disabledType]
);
const relationCount = relationsResult[0]?.count || 0;
const sqlQueries = [
{ sql: 'Initial count query', result: initialCountResult },
{ sql: 'Final count query', result: finalCountResult },
{ sql: 'Relations query', result: relationsResult },
];
updateTestResult(testId, {
status: 'success',
message: `Disabled type ${disabledType}: ${initialCount}${finalCount} objects. ${relationCount} relations found.`,
data: {
initialCount,
finalCount,
relationCount,
delta: finalCount - initialCount,
},
sqlQueries,
});
} catch (error) {
updateTestResult(testId, {
status: 'error',
message: error instanceof Error ? error.message : 'Test failed',
});
} finally {
setLoading(prev => ({ ...prev, [testId]: false }));
}
};
// Test C: Attribute wipe rule
const runTestC = async () => {
const testId = 'testC';
setLoading(prev => ({ ...prev, [testId]: true }));
updateTestResult(testId, { test: 'Test C: Attribute Wipe Rule', status: 'running' });
try {
if (!inputs.referencedObjectKey) {
throw new Error('Please provide a referencedObjectKey to test');
}
// Step 1: Get initial attribute count
updateTestResult(testId, { message: 'Checking initial attribute values...' });
const objInfoResponse = await fetch(`/api/v2/debug/objects?objectKey=${inputs.referencedObjectKey}`, {
credentials: 'include',
});
if (!objInfoResponse.ok) throw new Error('Failed to get object info');
const objInfo = await objInfoResponse.json();
const initialAttrCount = objInfo.attributeValueCount || 0;
if (initialAttrCount === 0) {
throw new Error('Object has no attributes - sync it first with level2 expansion');
}
// Step 2: Sync a parent object that references this one (should come as shallow)
updateTestResult(testId, { message: 'Syncing parent object (referenced object may come as shallow)...' });
const syncResponse = await fetch(`/api/v2/sync/objects/${inputs.typeName}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
});
if (!syncResponse.ok) throw new Error('Sync failed');
// Step 3: Check final attribute count (should be same or greater)
updateTestResult(testId, { message: 'Checking final attribute values...' });
const finalObjInfoResponse = await fetch(`/api/v2/debug/objects?objectKey=${inputs.referencedObjectKey}`, {
credentials: 'include',
});
if (!finalObjInfoResponse.ok) throw new Error('Failed to get final object info');
const finalObjInfo = await finalObjInfoResponse.json();
const finalAttrCount = finalObjInfo.attributeValueCount || 0;
const sqlQueries = [
{ sql: 'Initial attribute count', result: [{ count: initialAttrCount }] },
{ sql: 'Final attribute count', result: [{ count: finalAttrCount }] },
];
const success = finalAttrCount >= initialAttrCount;
updateTestResult(testId, {
status: success ? 'success' : 'error',
message: success
? `✅ Attributes preserved: ${initialAttrCount}${finalAttrCount}`
: `❌ Attributes were wiped: ${initialAttrCount}${finalAttrCount}`,
data: {
initialAttrCount,
finalAttrCount,
preserved: success,
},
sqlQueries,
});
} catch (error) {
updateTestResult(testId, {
status: 'error',
message: error instanceof Error ? error.message : 'Test failed',
});
} finally {
setLoading(prev => ({ ...prev, [testId]: false }));
}
};
// Test D: QueryService reconstruction
const runTestD = async () => {
const testId = 'testD';
setLoading(prev => ({ ...prev, [testId]: true }));
updateTestResult(testId, { test: 'Test D: QueryService Reconstruction', status: 'running' });
try {
if (!inputs.objectKey) {
throw new Error('Please provide an objectKey to test');
}
// Step 1: Get object from DB via QueryService
updateTestResult(testId, { message: 'Fetching object via QueryService...' });
const objectResponse = await fetch(`/api/v2/objects/${inputs.typeName}/${inputs.objectKey}`, {
credentials: 'include',
});
if (!objectResponse.ok) throw new Error('Failed to fetch object');
const object = await objectResponse.json();
// Step 2: Get raw DB data for comparison
updateTestResult(testId, { message: 'Fetching raw DB data...' });
const objInfoResponse = await fetch(`/api/v2/debug/objects?objectKey=${inputs.objectKey}`, {
credentials: 'include',
});
if (!objInfoResponse.ok) throw new Error('Failed to get object info');
const objInfo = await objInfoResponse.json();
// Step 3: Get attribute values from DB
const attrValuesResult = await executeSqlQuery(
`SELECT av.*, a.field_name, a.attr_type, a.is_multiple
FROM attribute_values av
JOIN attributes a ON a.id = av.attribute_id
WHERE av.object_id = ?
ORDER BY a.field_name, av.array_index`,
[objInfo.object.id]
);
// Step 4: Verify reconstruction
const fieldCount = new Set(attrValuesResult.map((av: any) => av.field_name)).size;
const objectFieldCount = Object.keys(object).filter(k => !k.startsWith('_') && k !== 'id' && k !== 'objectKey' && k !== 'label').length;
const sqlQueries = [
{ sql: 'Attribute values query', result: attrValuesResult },
];
updateTestResult(testId, {
status: 'success',
message: `Object reconstructed: ${fieldCount} fields from DB, ${objectFieldCount} fields in object`,
data: {
object,
dbFields: fieldCount,
objectFields: objectFieldCount,
attributeValues: attrValuesResult.length,
},
sqlQueries,
});
} catch (error) {
updateTestResult(testId, {
status: 'error',
message: error instanceof Error ? error.message : 'Test failed',
});
} finally {
setLoading(prev => ({ ...prev, [testId]: false }));
}
};
// Test E: Write-through
const runTestE = async () => {
const testId = 'testE';
setLoading(prev => ({ ...prev, [testId]: true }));
updateTestResult(testId, { test: 'Test E: Write-Through Update', status: 'running' });
try {
if (!inputs.objectKey) {
throw new Error('Please provide an objectKey to test');
}
// Step 1: Get initial object state
updateTestResult(testId, { message: 'Fetching initial object state...' });
const initialResponse = await fetch(`/api/v2/objects/${inputs.typeName}/${inputs.objectKey}`, {
credentials: 'include',
});
if (!initialResponse.ok) throw new Error('Failed to fetch initial object');
const initialObject = await initialResponse.json();
// Step 2: Update via write-through
updateTestResult(testId, { message: 'Updating object via write-through...' });
const updatePayload: Record<string, any> = {};
updatePayload[inputs.updateField] = inputs.updateValue;
const updateResponse = await fetch(`/api/v2/objects/${inputs.typeName}/${inputs.objectKey}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(updatePayload),
});
if (!updateResponse.ok) {
const error = await updateResponse.json();
throw new Error(error.error || 'Update failed');
}
const updateResult = await updateResponse.json();
// Step 3: Immediately check DB (without refresh)
updateTestResult(testId, { message: 'Checking DB state immediately...' });
const dbCheckResponse = await fetch(`/api/v2/objects/${inputs.typeName}/${inputs.objectKey}`, {
credentials: 'include',
});
if (!dbCheckResponse.ok) throw new Error('Failed to fetch updated object');
const dbObject = await dbCheckResponse.json();
// Step 4: Check attribute value in DB
const attrCheckResult = await executeSqlQuery(
`SELECT av.*, a.field_name
FROM attribute_values av
JOIN attributes a ON a.id = av.attribute_id
JOIN objects o ON o.id = av.object_id
WHERE o.object_key = ? AND a.field_name = ?`,
[inputs.objectKey, inputs.updateField]
);
const sqlQueries = [
{ sql: 'Attribute value check', result: attrCheckResult },
];
const dbValue = dbObject[inputs.updateField];
const updated = dbValue === inputs.updateValue || (typeof dbValue === 'object' && dbValue?.label === inputs.updateValue);
updateTestResult(testId, {
status: updated ? 'success' : 'error',
message: updated
? `✅ Write-through successful: DB updated immediately`
: `❌ Write-through failed: DB value doesn't match`,
data: {
initialValue: initialObject[inputs.updateField],
expectedValue: inputs.updateValue,
dbValue,
updated,
},
sqlQueries,
});
} catch (error) {
updateTestResult(testId, {
status: 'error',
message: error instanceof Error ? error.message : 'Test failed',
});
} finally {
setLoading(prev => ({ ...prev, [testId]: false }));
}
};
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<PageHeader
title="Architecture Debug & Test Page"
description="Test the refactored V2 API architecture. All tests require admin permissions."
icon={
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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>
}
/>
{/* Input Form */}
<div className="bg-white shadow rounded-lg p-6 mb-8">
<h2 className="text-xl font-semibold mb-4">Test Parameters</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Object Type Name
</label>
<div className="flex gap-2">
<input
type="text"
value={inputs.typeName}
onChange={(e) => setInputs({ ...inputs, typeName: e.target.value })}
className="flex-1 border border-gray-300 rounded-md px-3 py-2"
placeholder="ApplicationComponent"
list="enabled-types-list"
/>
<datalist id="enabled-types-list">
{enabledTypes.map(type => (
<option key={type} value={type} />
))}
</datalist>
</div>
{!loadingEnabledTypes && enabledTypes.length > 0 && (
<p className="mt-1 text-xs text-gray-500">
Enabled types: {enabledTypes.join(', ')}
{!enabledTypes.includes(inputs.typeName) && inputs.typeName && (
<span className="ml-2 text-orange-600">
"{inputs.typeName}" is not enabled
</span>
)}
</p>
)}
{!loadingEnabledTypes && enabledTypes.length === 0 && (
<p className="mt-1 text-xs text-orange-600">
No object types are currently enabled. Please enable at least one in Schema Configuration.
</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Object Key (for Tests C, D, E)
</label>
<input
type="text"
value={inputs.objectKey}
onChange={(e) => setInputs({ ...inputs, objectKey: e.target.value })}
className="w-full border border-gray-300 rounded-md px-3 py-2"
placeholder="APP-123"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Referenced Object Key (for Tests A, C)
</label>
<input
type="text"
value={inputs.referencedObjectKey}
onChange={(e) => setInputs({ ...inputs, referencedObjectKey: e.target.value })}
className="w-full border border-gray-300 rounded-md px-3 py-2"
placeholder="HOST-123"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Disabled Type Name (for Test B)
</label>
<input
type="text"
value={inputs.disabledTypeName}
onChange={(e) => setInputs({ ...inputs, disabledTypeName: e.target.value })}
className="w-full border border-gray-300 rounded-md px-3 py-2"
placeholder="HostingType"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Update Field (for Test E)
</label>
<input
type="text"
value={inputs.updateField}
onChange={(e) => setInputs({ ...inputs, updateField: e.target.value })}
className="w-full border border-gray-300 rounded-md px-3 py-2"
placeholder="description"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Update Value (for Test E)
</label>
<input
type="text"
value={inputs.updateValue}
onChange={(e) => setInputs({ ...inputs, updateValue: e.target.value })}
className="w-full border border-gray-300 rounded-md px-3 py-2"
placeholder="Updated value"
/>
</div>
</div>
</div>
{/* Utility Actions */}
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-8">
<h2 className="text-lg font-semibold mb-2 text-yellow-900">Utility Actions</h2>
<div className="flex gap-2">
<button
onClick={async () => {
try {
const response = await fetch('/api/v2/debug/fix-missing-type-names', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
});
const result = await response.json();
if (result.success) {
alert(`Fixed ${result.fixed} object types with missing type_name. Errors: ${result.errors || 0}`);
// Reload enabled types
const config = await getConfiguredObjectTypes();
const enabled = config.schemas.flatMap(s =>
s.objectTypes.filter(ot => ot.enabled).map(ot => ot.objectTypeName)
);
setEnabledTypes(enabled);
} else {
alert(`Error: ${result.error || 'Failed to fix'}`);
}
} catch (error) {
alert(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}}
className="bg-yellow-600 text-white px-4 py-2 rounded-md hover:bg-yellow-700"
>
Fix Missing type_name
</button>
<button
onClick={async () => {
const testId = 'debug-all-types';
setLoading(prev => ({ ...prev, [testId]: true }));
updateTestResult(testId, { test: 'Debug: All Object Types', status: 'running', message: 'Fetching all object types...' });
try {
const response = await fetch('/api/v2/debug/all-object-types', {
credentials: 'include',
});
if (!response.ok) {
// Check if it's a 404 - likely means V2 API is not enabled
if (response.status === 404) {
throw new Error('V2 API routes are not enabled. Please set USE_V2_API=true in backend environment variables and restart the server.');
}
const errorData = await response.json().catch(() => ({ error: `HTTP ${response.status}: Failed to fetch` }));
const errorMessage = errorData.error || errorData.details || `HTTP ${response.status}: Failed to fetch`;
// Provide more context for common errors
if (response.status === 403) {
throw new Error(`${errorMessage} (Admin permission required)`);
}
throw new Error(errorMessage);
}
const result = await response.json();
// Format the data for display
const enabledWithNullTypeName = result.allTypes.filter((t: any) => t.enabled && !t.hasTypeName);
const enabledWithTypeName = result.allTypes.filter((t: any) => t.enabled && t.hasTypeName);
updateTestResult(testId, {
test: 'Debug: All Object Types',
status: 'success',
message: `Found ${result.summary.total} total types. ${result.summary.enabled} enabled in DB (${result.summary.enabledWithTypeName} with type_name, ${enabledWithNullTypeName.length} missing type_name). ${result.summary.missingTypeName} total missing type_name.`,
data: {
summary: result.summary,
enabledWithNullTypeName: enabledWithNullTypeName.map((t: any) => ({
id: t.id,
displayName: t.displayName,
enabled: t.enabled,
})),
enabledWithTypeName: enabledWithTypeName.map((t: any) => ({
id: t.id,
typeName: t.typeName,
displayName: t.displayName,
})),
allTypes: result.allTypes,
enabledTypesFromService: result.enabledTypes,
},
});
} catch (error) {
updateTestResult(testId, {
test: 'Debug: All Object Types',
status: 'error',
message: error instanceof Error ? error.message : 'Failed to fetch',
});
} finally {
setLoading(prev => ({ ...prev, [testId]: false }));
}
}}
disabled={loading.debugAllTypes}
className="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 disabled:bg-gray-400"
>
{loading.debugAllTypes ? 'Loading...' : 'Show All Object Types (Debug)'}
</button>
</div>
</div>
{/* Test Buttons */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-8">
<button
onClick={runTestA}
disabled={loading.testA}
className="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 disabled:bg-gray-400"
>
{loading.testA ? 'Running...' : 'Test A: Level2 Recursion'}
</button>
<button
onClick={runTestB}
disabled={loading.testB}
className="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 disabled:bg-gray-400"
>
{loading.testB ? 'Running...' : 'Test B: Disabled Type'}
</button>
<button
onClick={runTestC}
disabled={loading.testC}
className="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 disabled:bg-gray-400"
>
{loading.testC ? 'Running...' : 'Test C: Attribute Wipe Rule'}
</button>
<button
onClick={runTestD}
disabled={loading.testD}
className="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 disabled:bg-gray-400"
>
{loading.testD ? 'Running...' : 'Test D: QueryService Reconstruction'}
</button>
<button
onClick={runTestE}
disabled={loading.testE}
className="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 disabled:bg-gray-400"
>
{loading.testE ? 'Running...' : 'Test E: Write-Through'}
</button>
</div>
{/* Test Results */}
<div className="space-y-6">
{Object.entries(testResults).map(([testId, result]) => (
<div key={testId} className="bg-white shadow rounded-lg p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold">{result.test}</h3>
<span className={`px-3 py-1 rounded-full text-sm ${
result.status === 'success' ? 'bg-green-100 text-green-800' :
result.status === 'error' ? 'bg-red-100 text-red-800' :
result.status === 'running' ? 'bg-yellow-100 text-yellow-800' :
'bg-gray-100 text-gray-800'
}`}>
{result.status}
</span>
</div>
{result.message && (
<p className="text-gray-700 mb-4">{result.message}</p>
)}
{result.data && (
<div className="mb-4">
<h4 className="font-medium mb-2">Data:</h4>
<pre className="bg-gray-50 p-4 rounded-md overflow-auto text-sm">
{JSON.stringify(result.data, null, 2)}
</pre>
</div>
)}
{result.sqlQueries && result.sqlQueries.length > 0 && (
<div className="mb-4">
<h4 className="font-medium mb-2">SQL Queries & Results:</h4>
{result.sqlQueries.map((query, idx) => (
<div key={idx} className="mb-4 border-l-4 border-blue-500 pl-4">
<div className="font-mono text-sm bg-gray-50 p-2 rounded mb-2">
{query.sql}
</div>
<pre className="bg-gray-50 p-4 rounded-md overflow-auto text-xs max-h-64">
{JSON.stringify(query.result, null, 2)}
</pre>
</div>
))}
</div>
)}
{result.timestamp && (
<p className="text-xs text-gray-500">Ran at: {new Date(result.timestamp).toLocaleString()}</p>
)}
</div>
))}
</div>
{Object.keys(testResults).length === 0 && (
<div className="bg-gray-50 rounded-lg p-8 text-center text-gray-500">
No tests run yet. Use the buttons above to start testing.
</div>
)}
</div>
);
}

View File

@@ -3,6 +3,7 @@ import { Link } from 'react-router-dom';
import { clsx } from 'clsx';
import { getBIAComparison, updateApplication, getBusinessImpactAnalyses } from '../services/api';
import type { BIAComparisonItem, BIAComparisonResponse, ReferenceValue } from '../types';
import PageHeader from './PageHeader';
type MatchStatusFilter = 'all' | 'match' | 'mismatch' | 'not_found' | 'no_excel_bia';
type MatchTypeFilter = 'all' | 'exact' | 'search_reference' | 'fuzzy' | 'none';
@@ -261,16 +262,33 @@ export default function BIASyncDashboard() {
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 className="flex flex-col items-center justify-center h-96">
<div className="w-16 h-16 border-4 border-blue-600 border-t-transparent rounded-full animate-spin mb-4"></div>
<p className="text-gray-600 font-medium">BIA Sync data laden...</p>
</div>
);
}
if (error) {
return (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-red-700">
{error}
<div className="bg-red-50 border border-red-200 rounded-xl p-6 shadow-sm">
<div className="flex items-start gap-4">
<div className="flex-shrink-0">
<svg className="w-6 h-6 text-red-600" 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 className="flex-1">
<h3 className="text-lg font-semibold text-red-900 mb-1">Fout bij laden</h3>
<p className="text-red-700 mb-4">{error}</p>
<button
onClick={fetchData}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm font-medium"
>
Opnieuw proberen
</button>
</div>
</div>
</div>
);
}
@@ -325,78 +343,18 @@ export default function BIASyncDashboard() {
return (
<div className="space-y-6">
{/* Page header */}
<div>
<h2 className="text-2xl font-bold text-gray-900">BIA Sync Dashboard</h2>
<p className="text-gray-600">
Vergelijk Business Impact Analyse waarden uit Excel met de CMDB
</p>
</div>
{/* Summary cards */}
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
<div className="card p-4">
<div className="text-sm text-gray-500 mb-1">Totaal</div>
<div className="text-2xl font-bold text-gray-900">{data.summary.total}</div>
</div>
<div className="card p-4">
<div className="text-sm text-gray-500 mb-1">Match</div>
<div className="text-2xl font-bold text-green-600">{data.summary.matched}</div>
</div>
<div className="card p-4">
<div className="text-sm text-gray-500 mb-1">Verschil</div>
<div className="text-2xl font-bold text-red-600">{data.summary.mismatched}</div>
</div>
<div className="card p-4">
<div className="text-sm text-gray-500 mb-1">Niet in CMDB</div>
<div className="text-2xl font-bold text-yellow-600">{data.summary.notFound}</div>
</div>
<div className="card p-4">
<div className="text-sm text-gray-500 mb-1">Niet in Excel</div>
<div className="text-2xl font-bold text-gray-600">{data.summary.noExcelBIA}</div>
</div>
</div>
{/* Filters */}
<div className="card p-4">
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex-1">
<input
type="text"
placeholder="Zoek op naam, key of search reference..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as MatchStatusFilter)}
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="all">Alle statussen</option>
<option value="match">Match</option>
<option value="mismatch">Verschil</option>
<option value="not_found">Niet in CMDB</option>
<option value="no_excel_bia">Niet in Excel</option>
</select>
</div>
<div>
<select
value={matchTypeFilter}
onChange={(e) => setMatchTypeFilter(e.target.value as MatchTypeFilter)}
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="all">Alle match types</option>
<option value="exact">Exact</option>
<option value="search_reference">Zoekreferentie</option>
<option value="fuzzy">Fuzzy</option>
<option value="none">Geen match</option>
</select>
</div>
<PageHeader
title="BIA Sync Dashboard"
description="Vergelijk Business Impact Analyse waarden uit Excel met de CMDB"
icon={
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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>
}
actions={
<button
onClick={fetchData}
className="btn btn-secondary flex items-center space-x-2"
className="px-4 py-2 bg-white/20 hover:bg-white/30 backdrop-blur-sm rounded-lg transition-colors flex items-center gap-2 text-sm font-medium"
>
<svg
className="w-4 h-4"
@@ -413,35 +371,145 @@ export default function BIASyncDashboard() {
</svg>
<span>Ververs</span>
</button>
}
/>
{/* Summary cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4">
<div className="bg-white rounded-xl shadow-md border border-gray-200 p-5 hover:shadow-lg transition-shadow">
<div className="flex items-center justify-between mb-2">
<div className="text-sm font-medium text-gray-600">Totaal</div>
<div className="w-8 h-8 bg-gray-100 rounded-lg flex items-center justify-center">
<svg className="w-4 h-4 text-gray-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>
<div className="text-3xl font-bold text-gray-900">{data.summary.total}</div>
</div>
<div className="bg-gradient-to-br from-green-50 to-emerald-50 rounded-xl shadow-md border border-green-200 p-5 hover:shadow-lg transition-shadow">
<div className="flex items-center justify-between mb-2">
<div className="text-sm font-medium text-green-700">Match</div>
<div className="w-8 h-8 bg-green-100 rounded-lg flex items-center justify-center">
<svg className="w-4 h-4 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
</div>
<div className="text-3xl font-bold text-green-700">{data.summary.matched}</div>
</div>
<div className="bg-gradient-to-br from-red-50 to-rose-50 rounded-xl shadow-md border border-red-200 p-5 hover:shadow-lg transition-shadow">
<div className="flex items-center justify-between mb-2">
<div className="text-sm font-medium text-red-700">Verschil</div>
<div className="w-8 h-8 bg-red-100 rounded-lg flex items-center justify-center">
<svg className="w-4 h-4 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
</div>
<div className="text-3xl font-bold text-red-700">{data.summary.mismatched}</div>
</div>
<div className="bg-gradient-to-br from-yellow-50 to-amber-50 rounded-xl shadow-md border border-yellow-200 p-5 hover:shadow-lg transition-shadow">
<div className="flex items-center justify-between mb-2">
<div className="text-sm font-medium text-yellow-700">Niet in CMDB</div>
<div className="w-8 h-8 bg-yellow-100 rounded-lg flex items-center justify-center">
<svg className="w-4 h-4 text-yellow-600" 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>
<div className="text-3xl font-bold text-yellow-700">{data.summary.notFound}</div>
</div>
<div className="bg-gradient-to-br from-gray-50 to-slate-50 rounded-xl shadow-md border border-gray-200 p-5 hover:shadow-lg transition-shadow">
<div className="flex items-center justify-between mb-2">
<div className="text-sm font-medium text-gray-700">Niet in Excel</div>
<div className="w-8 h-8 bg-gray-100 rounded-lg flex items-center justify-center">
<svg className="w-4 h-4 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
</div>
<div className="text-3xl font-bold text-gray-700">{data.summary.noExcelBIA}</div>
</div>
</div>
{/* Filters */}
<div className="bg-white rounded-xl shadow-md border border-gray-200 p-5">
<div className="flex flex-col sm:flex-row gap-3">
<div className="flex-1">
<label className="block text-xs font-medium text-gray-700 mb-1.5">Zoeken</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg className="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<input
type="text"
placeholder="Zoek op naam, key of search reference..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="w-full pl-10 pr-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
/>
</div>
</div>
<div className="sm:w-48">
<label className="block text-xs font-medium text-gray-700 mb-1.5">Status</label>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as MatchStatusFilter)}
className="w-full px-3 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors bg-white"
>
<option value="all">Alle statussen</option>
<option value="match">Match</option>
<option value="mismatch">Verschil</option>
<option value="not_found">Niet in CMDB</option>
<option value="no_excel_bia">Niet in Excel</option>
</select>
</div>
<div className="sm:w-48">
<label className="block text-xs font-medium text-gray-700 mb-1.5">Match Type</label>
<select
value={matchTypeFilter}
onChange={(e) => setMatchTypeFilter(e.target.value as MatchTypeFilter)}
className="w-full px-3 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors bg-white"
>
<option value="all">Alle match types</option>
<option value="exact">Exact</option>
<option value="search_reference">Zoekreferentie</option>
<option value="fuzzy">Fuzzy</option>
<option value="none">Geen match</option>
</select>
</div>
</div>
</div>
{/* Table */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200">
<div className="bg-white rounded-xl shadow-md border border-gray-200 overflow-hidden">
<div className="overflow-x-auto">
{/* Placeholder spacer when header is fixed */}
{isHeaderFixed && headerHeight > 0 && (
<div style={{ height: `${headerHeight}px` }} className="bg-transparent" />
)}
<table ref={tableRef} className="min-w-full divide-y divide-gray-200">
<thead ref={theadRef} className="bg-gray-50 sticky top-0 z-10 shadow-sm">
<thead ref={theadRef} className="bg-gradient-to-r from-gray-50 to-gray-100 sticky top-0 z-10 shadow-sm">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">
<th className="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider bg-gradient-to-r from-gray-50 to-gray-100 w-64">
Applicatie
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">
<th className="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider bg-gradient-to-r from-gray-50 to-gray-100 w-32">
BIA (CMDB)
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">
<th className="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider bg-gradient-to-r from-gray-50 to-gray-100 w-32">
BIA (Excel)
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">
<th className="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider bg-gradient-to-r from-gray-50 to-gray-100 w-28">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">
<th className="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider bg-gradient-to-r from-gray-50 to-gray-100 w-32">
Match Type
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">
<th className="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider bg-gradient-to-r from-gray-50 to-gray-100 w-24">
Actie
</th>
</tr>
@@ -455,150 +523,154 @@ export default function BIASyncDashboard() {
</tr>
) : (
filteredApplications.map((item) => (
<tr key={item.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div>
<div className="text-sm font-medium text-gray-900">
<Link
to={`/application/${item.id}`}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 hover:underline"
onClick={(e) => e.stopPropagation()}
>
{item.name}
</Link>
</div>
<div className="text-sm text-gray-500">
{item.key}
{item.searchReference && `${item.searchReference}`}
</div>
{item.excelApplicationName && item.excelApplicationName !== item.name && (
<div className="text-xs text-gray-400 mt-1">
Excel: {item.excelApplicationName}
</div>
)}
{item.allMatches && item.allMatches.length > 1 && (
<div className="mt-1">
<button
onClick={() => {
setExpandedMatches(prev => {
const next = new Set(prev);
if (next.has(item.id)) {
next.delete(item.id);
} else {
next.add(item.id);
}
return next;
});
}}
className="text-xs text-blue-600 hover:text-blue-800 hover:underline flex items-center gap-1"
<>
<tr key={item.id} className="hover:bg-blue-50/30 transition-colors border-b border-gray-100">
<td className="px-6 py-4 w-64">
<div>
<div className="text-sm font-medium text-gray-900">
<Link
to={`/application/${item.id}`}
className="text-blue-600 hover:text-blue-800 hover:underline"
onClick={(e) => e.stopPropagation()}
>
<span> {item.allMatches.length} matches gevonden</span>
<svg
className={`w-3 h-3 transition-transform ${expandedMatches.has(item.id) ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{expandedMatches.has(item.id) && (
<div className="mt-2 p-2 bg-blue-50 rounded border border-blue-200 text-xs">
<div className="font-semibold text-blue-900 mb-1">Alle gevonden matches:</div>
<ul className="space-y-1">
{item.allMatches.map((match, idx) => (
<li key={idx} className={match.excelApplicationName === item.excelApplicationName ? 'font-semibold text-blue-900' : 'text-blue-700'}>
{idx + 1}. "{match.excelApplicationName}" BIA: {match.biaValue} ({match.matchType}, {(match.confidence * 100).toFixed(0)}%)
{match.excelApplicationName === item.excelApplicationName && ' ← Huidige selectie'}
</li>
))}
</ul>
</div>
)}
{item.name}
</Link>
</div>
)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">
{item.currentBIA ? (
<span className="font-medium">{item.currentBIA.name}</span>
) : (
<span className="text-gray-400">-</span>
)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">
{item.excelBIA ? (
<span className="font-medium">{item.excelBIA}</span>
) : (
<span className="text-gray-400">-</span>
)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={clsx(
'inline-flex px-2 py-1 text-xs font-semibold rounded-full',
getStatusBadgeClass(item.matchStatus)
)}
>
{getStatusLabel(item.matchStatus)}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-500">
{getMatchTypeLabel(item.matchType)}
{item.matchConfidence !== undefined && (
<span className="ml-1 text-xs">
({(item.matchConfidence * 100).toFixed(0)}%)
</span>
)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
{item.excelBIA && item.matchStatus !== 'match' && (
<button
onClick={() => handleSave(item)}
disabled={savingIds.has(item.id)}
<div className="text-sm text-gray-500">
{item.key}
{item.searchReference && `${item.searchReference}`}
</div>
{item.excelApplicationName && item.excelApplicationName !== item.name && (
<div className="text-xs text-gray-400 mt-1">
Excel: {item.excelApplicationName}
</div>
)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap w-32">
<div className="text-sm text-gray-900">
{item.currentBIA ? (
<span className="font-medium">{item.currentBIA.name}</span>
) : (
<span className="text-gray-400">-</span>
)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap w-32">
<div className="text-sm text-gray-900">
{item.excelBIA ? (
<span className="font-medium">{item.excelBIA}</span>
) : (
<span className="text-gray-400">-</span>
)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap w-28">
<span
className={clsx(
'text-blue-600 hover:text-blue-900',
savingIds.has(item.id) && 'opacity-50 cursor-not-allowed'
'inline-flex px-2 py-1 text-xs font-semibold rounded-full',
getStatusBadgeClass(item.matchStatus)
)}
>
{savingIds.has(item.id) ? (
<span className="flex items-center space-x-1">
<svg
className="animate-spin h-4 w-4"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<span>Opslaan...</span>
{getStatusLabel(item.matchStatus)}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap w-32">
<div className="text-sm text-gray-500">
{getMatchTypeLabel(item.matchType)}
{item.matchConfidence !== undefined && (
<span className="ml-1 text-xs">
({(item.matchConfidence * 100).toFixed(0)}%)
</span>
) : (
'Opslaan'
)}
</button>
)}
</td>
</tr>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap w-24">
{item.excelBIA && item.matchStatus !== 'match' && (
<button
onClick={() => handleSave(item)}
disabled={savingIds.has(item.id)}
className={clsx(
'px-2.5 py-1 text-xs font-medium rounded-md transition-colors',
savingIds.has(item.id)
? 'bg-gray-200 text-gray-500 cursor-not-allowed'
: 'bg-blue-600 text-white hover:bg-blue-700 active:bg-blue-800'
)}
>
{savingIds.has(item.id) ? (
<span className="flex items-center space-x-1.5">
<svg
className="animate-spin h-3 w-3"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<span>Opslaan...</span>
</span>
) : (
'Opslaan'
)}
</button>
)}
</td>
</tr>
{item.allMatches && item.allMatches.length > 1 && (
<tr key={`${item.id}-matches`} className="bg-blue-50/20 border-b border-blue-100">
<td colSpan={6} className="px-6 py-2">
<button
onClick={() => {
setExpandedMatches(prev => {
const next = new Set(prev);
if (next.has(item.id)) {
next.delete(item.id);
} else {
next.add(item.id);
}
return next;
});
}}
className="w-full text-left text-xs text-blue-600 hover:text-blue-800 hover:underline flex items-center gap-2 py-0.5"
>
<svg
className={`w-3 h-3 transition-transform ${expandedMatches.has(item.id) ? 'rotate-90' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
<span className="text-xs"> {item.allMatches.length} matches gevonden</span>
</button>
{expandedMatches.has(item.id) && (
<div className="mt-1.5 p-2.5 bg-blue-50 rounded border border-blue-200">
<div className="font-semibold text-blue-900 mb-1.5 text-xs">Alle gevonden matches:</div>
<ul className="space-y-1">
{item.allMatches.map((match, idx) => (
<li key={idx} className={`text-xs ${match.excelApplicationName === item.excelApplicationName ? 'font-semibold text-blue-900' : 'text-blue-700'}`}>
{idx + 1}. "{match.excelApplicationName}" BIA: {match.biaValue} ({match.matchType}, {(match.confidence * 100).toFixed(0)}%)
{match.excelApplicationName === item.excelApplicationName && ' ← Huidige selectie'}
</li>
))}
</ul>
</div>
)}
</td>
</tr>
)}
</>
))
)}
</tbody>
@@ -607,8 +679,18 @@ export default function BIASyncDashboard() {
</div>
{/* Results count */}
<div className="text-sm text-gray-500">
{filteredApplications.length} van {data.applications.length} applicaties getoond
<div className="flex items-center justify-between bg-gray-50 rounded-lg px-4 py-3 border border-gray-200">
<div className="text-sm font-medium text-gray-700">
<span className="text-gray-900 font-semibold">{filteredApplications.length}</span>
<span className="text-gray-500"> van </span>
<span className="text-gray-900 font-semibold">{data.applications.length}</span>
<span className="text-gray-500"> applicaties getoond</span>
</div>
{filteredApplications.length !== data.applications.length && (
<div className="text-xs text-gray-500 bg-white px-2 py-1 rounded border border-gray-200">
Gefilterd
</div>
)}
</div>
</div>
);

View File

@@ -3,6 +3,7 @@ import { ScatterChart, Scatter, XAxis, YAxis, CartesianGrid, Tooltip, Legend, Re
import { getBusinessImportanceComparison } from '../services/api';
import type { BusinessImportanceComparisonItem } from '../types';
import { Link } from 'react-router-dom';
import PageHeader from './PageHeader';
interface ScatterDataPoint {
x: number; // Business Importance (0-6)
@@ -138,12 +139,15 @@ export default function BusinessImportanceComparison() {
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-gray-900">Business Importance vs Business Impact Analysis</h1>
<p className="mt-1 text-gray-500">
Vergelijking tussen IT-infrastructuur prioritering (Business Importance) en business owner beoordeling (Business Impact Analysis)
</p>
</div>
<PageHeader
title="Business Importance vs Business Impact Analysis"
description="Vergelijking tussen IT-infrastructuur prioritering (Business Importance) en business owner beoordeling (Business Impact Analysis)"
icon={
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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>
}
/>
{/* Summary Statistics Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-6 gap-4">

View File

@@ -1,5 +1,6 @@
import React, { useState, useEffect } from 'react';
import { getCacheStatus, triggerSync, type CacheStatus } from '../services/api';
import { getCacheStatus, triggerSync, type CacheStatus, ApiError } from '../services/api';
import { toastManager } from './Toast';
interface CacheStatusIndicatorProps {
/** Show compact version (just icon + time) */
@@ -15,15 +16,15 @@ export const CacheStatusIndicator: React.FC<CacheStatusIndicatorProps> = ({
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 [isExpanded, setIsExpanded] = useState(false);
const fetchStatus = async () => {
try {
const data = await getCacheStatus();
setStatus(data);
setError(null);
} catch (err) {
setError('Kon status niet ophalen');
const errorMessage = err instanceof Error ? err.message : 'Kon status niet ophalen';
toastManager.error(errorMessage, 6000);
} finally {
setLoading(false);
}
@@ -40,54 +41,67 @@ export const CacheStatusIndicator: React.FC<CacheStatusIndicatorProps> = ({
setSyncing(true);
try {
await triggerSync();
toastManager.success('Synchronisatie gestart. Dit kan even duren...');
// Refetch status after a short delay
setTimeout(fetchStatus, 1000);
} catch (err) {
setError('Sync mislukt');
console.error('Sync failed:', err);
let errorMessage = 'Sync mislukt';
if (err instanceof ApiError) {
errorMessage = err.message || 'Sync mislukt';
// Check if it's a configuration error
if (err.status === 400 && err.data && typeof err.data === 'object' && 'message' in err.data) {
errorMessage = String(err.data.message);
}
} else if (err instanceof Error) {
errorMessage = err.message;
} else if (typeof err === 'object' && err !== null && 'error' in err) {
errorMessage = String(err.error);
} else if (typeof err === 'string') {
errorMessage = err;
}
// Show error in toast notification only
toastManager.error(errorMessage, 8000);
} 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';
// Handle compact mode early returns
if (compact) {
if (loading) {
return (
<div className={`inline-flex items-center gap-2 text-xs 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>
<span>Laden...</span>
</div>
);
}
if (!status) {
return (
<div className={`inline-flex items-center gap-2 text-xs text-gray-500`}>
<span>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;
const statusColor = !isWarm
? 'text-amber-600'
: ageMinutes !== null && ageMinutes > 5
? 'text-amber-600'
: 'text-emerald-600';
return (
<button
onClick={handleSync}
@@ -110,68 +124,141 @@ export const CacheStatusIndicator: React.FC<CacheStatusIndicatorProps> = ({
);
}
// Calculate status values with defaults for loading/error states
const lastSync = status?.cache.lastIncrementalSync;
const ageMinutes = lastSync
? Math.floor((Date.now() - new Date(lastSync).getTime()) / 60000)
: null;
const isWarm = status?.cache.isWarm ?? false;
const isSyncing = status?.sync.isSyncing || syncing;
const totalObjects = status?.cache.totalObjects ?? 0;
// Status color
const statusColor = !isWarm
? 'text-amber-600'
: ageMinutes !== null && ageMinutes > 5
? 'text-amber-600'
: 'text-emerald-600';
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"
<div className="border-b border-gray-200">
<div
className="px-6 py-4 bg-gray-50 cursor-pointer hover:bg-gray-100 transition-colors"
onClick={() => setIsExpanded(!isExpanded)}
>
{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 className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button
onClick={(e) => {
e.stopPropagation();
setIsExpanded(!isExpanded);
}}
className="text-gray-400 hover:text-gray-600 transition-colors p-1 rounded hover:bg-gray-200"
>
<svg
className={`w-5 h-5 transition-transform duration-200 ${isExpanded ? 'rotate-90' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
<h3 className="text-sm font-semibold text-gray-900">Cache Status</h3>
</div>
{!isExpanded && (
<div className="flex items-center gap-2">
{loading ? (
<span className="text-sm text-gray-500">Laden...</span>
) : status ? (
<>
<span className="text-sm text-gray-600">
<span className="font-mono font-semibold">{totalObjects.toLocaleString()}</span> objecten
</span>
<span className={`text-xs font-semibold px-3 py-1 rounded-full ${isWarm ? 'bg-emerald-100 text-emerald-700 border border-emerald-200' : 'bg-amber-100 text-amber-700 border border-amber-200'}`}>
{isWarm ? 'Actief' : 'Cold Start'}
</span>
</>
) : (
<span className="text-sm text-gray-500">Geen data</span>
)}
</div>
)}
</div>
</div>
{isExpanded && (
<div className="px-6 py-5 bg-gray-50">
<div className="space-y-3 text-sm mb-4">
{loading ? (
<div className="flex items-center justify-center py-4 text-gray-500">
<svg className="animate-spin h-5 w-5 mr-2" 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>Status laden...</span>
</div>
) : !status ? (
<div className="flex items-center justify-center py-4 text-gray-500">
<span>Geen status beschikbaar</span>
</div>
) : (
<>
<div className="flex justify-between items-center py-1">
<span className="text-gray-600">Objecten in cache:</span>
<span className="text-gray-900 font-mono font-semibold">{status.cache.totalObjects.toLocaleString()}</span>
</div>
<div className="flex justify-between items-center py-1">
<span className="text-gray-600">Relaties:</span>
<span className="text-gray-900 font-mono font-semibold">{status.cache.totalRelations.toLocaleString()}</span>
</div>
<div className="flex justify-between items-center py-1 border-b border-gray-100 pb-3">
<span className="text-gray-600">Laatst gesynchroniseerd:</span>
<span className={`font-mono font-semibold ${statusColor}`}>
{ageMinutes !== null ? `${ageMinutes} min geleden` : 'Nooit'}
</span>
</div>
</>
)}
{isSyncing && (
<div className="flex items-center gap-2 text-blue-600 bg-blue-50 rounded-lg px-3 py-2 border border-blue-200">
<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 className="font-medium">Synchronisatie bezig...</span>
</div>
)}
</div>
<button
onClick={(e) => {
e.stopPropagation();
handleSync();
}}
disabled={isSyncing}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg bg-gradient-to-r from-blue-600 to-blue-700 text-white hover:from-blue-700 hover:to-blue-800 transition-all disabled:opacity-50 disabled:cursor-not-allowed text-sm font-semibold shadow-md hover:shadow-lg disabled:hover:shadow-md"
>
{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>
)}
</div>
);
};

View File

@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { ScatterChart, Scatter, XAxis, YAxis, ZAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell } from 'recharts';
import { searchApplications } from '../services/api';
import type { ReferenceValue, ApplicationStatus } from '../types';
import PageHeader from './PageHeader';
import { Link } from 'react-router-dom';
const ALL_STATUSES: ApplicationStatus[] = [
@@ -170,12 +171,15 @@ export default function ComplexityDynamicsBubbleChart() {
return (
<div>
{/* Header */}
<div className="mb-8">
<h1 className="text-2xl font-bold text-gray-900">Complexity vs Dynamics Bubble Chart</h1>
<p className="mt-1 text-gray-500">
X-as: Complexity, Y-as: Dynamics, Grootte: FTE, Kleur: BIA
</p>
</div>
<PageHeader
title="Complexity vs Dynamics Bubble Chart"
description="X-as: Complexity, Y-as: Dynamics, Grootte: FTE, Kleur: BIA"
icon={
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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>
}
/>
{/* Filters */}
<div className="mb-6">

View File

@@ -3,6 +3,7 @@ import { Link } from 'react-router-dom';
import { getDashboardStats, getRecentClassifications, getReferenceData } from '../services/api';
import type { DashboardStats, ClassificationResult, ReferenceValue } from '../types';
import GovernanceModelBadge from './GovernanceModelBadge';
import PageHeader from './PageHeader';
// Extended type to include stale indicator from API
interface DashboardStatsWithMeta extends DashboardStats {
@@ -74,45 +75,64 @@ export default function Dashboard() {
return (
<div className="space-y-6">
{/* Page header */}
<div className="flex justify-between items-center">
<div>
<h2 className="text-2xl font-bold text-gray-900">Dashboard</h2>
<p className="text-gray-600">
<PageHeader
title="Dashboard"
description={
<>
Overzicht van de ZiRA classificatie voortgang
{stats?.stale && (
<span className="ml-2 text-amber-600 text-sm">
<span className="ml-2 text-amber-200 text-sm">
(gecachte data - API timeout)
</span>
)}
</p>
</div>
<div className="flex items-center space-x-3">
<button
onClick={handleRefresh}
disabled={refreshing}
className="btn btn-secondary flex items-center space-x-2"
title="Ververs data"
>
<svg
className={`w-4 h-4 ${refreshing ? 'animate-spin' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
</>
}
icon={
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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>
}
actions={
<>
<button
onClick={handleRefresh}
disabled={refreshing}
className="px-4 py-2 bg-white/20 hover:bg-white/30 backdrop-blur-sm rounded-lg transition-colors flex items-center gap-2 text-sm font-medium disabled:opacity-50"
title="Ververs data"
>
<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>{refreshing ? 'Laden...' : 'Ververs'}</span>
</button>
<Link to="/app-components/overview" className="btn btn-primary">
Start classificeren
</Link>
</div>
</div>
<svg
className={`w-4 h-4 ${refreshing ? '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>
<span>{refreshing ? 'Laden...' : 'Ververs'}</span>
</button>
<Link
to="/application/overview"
className="px-4 py-2 bg-white text-blue-600 rounded-lg hover:bg-blue-50 transition-colors font-medium text-sm"
>
Start classificeren
</Link>
</>
}
progress={
stats
? {
current: stats.classifiedCount || 0,
total: stats.totalApplications || 0,
label: 'Classificatie voortgang',
}
: undefined
}
/>
{/* Stats cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">

View File

@@ -8,6 +8,7 @@ import {
type CompletenessCategoryConfig,
type SchemaResponse,
} from '../services/api';
import PageHeader from './PageHeader';
// Mapping from schema fieldName to ApplicationDetails field path
// Some fields have different names in ApplicationDetails vs schema
@@ -456,13 +457,15 @@ export default function DataCompletenessConfig() {
return (
<div>
{/* Header */}
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900">Data Completeness Configuration</h1>
<p className="mt-1 text-sm text-gray-500">
Create and configure categories and fields for the Data Completeness Score calculation.
Fields are dynamically loaded from the "Application Component" object type in the datamodel.
</p>
</div>
<PageHeader
title="Data Completeness Configuration"
description="Create and configure categories and fields for the Data Completeness Score calculation. Fields are dynamically loaded from the 'Application Component' object type in the datamodel."
icon={
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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>
}
/>
{/* Success/Error Messages */}
{success && (

View File

@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import type { DataCompletenessConfig } from '../services/api';
import PageHeader from './PageHeader';
interface FieldCompleteness {
field: string;
@@ -145,12 +146,15 @@ export default function DataCompletenessScore() {
return (
<div>
{/* Header */}
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900">Data Completeness Score</h1>
<p className="mt-1 text-sm text-gray-500">
Percentage van verplichte velden ingevuld per applicatie, per team en overall.
</p>
</div>
<PageHeader
title="Data Completeness Score"
description="Percentage van verplichte velden ingevuld per applicatie, per team en overall"
icon={
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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>
}
/>
{/* Overall Score Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">

View File

@@ -38,6 +38,9 @@ function AttributeRow({ attr, onReferenceClick }: { attr: SchemaAttributeDefinit
<td className="px-3 py-2 text-sm text-gray-500 font-mono text-xs">
{attr.fieldName}
</td>
<td className="px-3 py-2 text-center text-sm text-gray-500">
{attr.position !== undefined && attr.position !== null ? attr.position : '—'}
</td>
<td className="px-3 py-2">
<TypeBadge type={attr.type} />
{attr.isMultiple && (
@@ -103,6 +106,8 @@ function ObjectTypeCard({
cacheCount?: number;
jiraCount?: number;
}) {
// Attributes are already sorted by position from the database (ORDER BY position, jira_attr_id)
// We only need to separate reference and non-reference attributes
const referenceAttrs = objectType.attributes.filter(a => a.type === 'reference');
const nonReferenceAttrs = objectType.attributes.filter(a => a.type !== 'reference');
@@ -125,7 +130,19 @@ function ObjectTypeCard({
</svg>
</div>
<div>
<h3 className="font-semibold text-gray-900">{objectType.name}</h3>
<div className="flex items-center gap-2">
<h3 className="font-semibold text-gray-900">{objectType.name}</h3>
{/* Sync Status Badge */}
{objectType.enabled ? (
<span className="px-2 py-0.5 text-xs font-medium bg-green-100 text-green-700 rounded-full" title="Enabled - Objects are being synced">
Enabled
</span>
) : (
<span className="px-2 py-0.5 text-xs font-medium bg-gray-100 text-gray-600 rounded-full" title="Disabled - Objects are not being synced">
Disabled
</span>
)}
</div>
<p className="text-xs text-gray-500 font-mono">{objectType.typeName}</p>
</div>
</div>
@@ -133,6 +150,11 @@ function ObjectTypeCard({
<div className="text-right">
<div className="text-sm font-medium text-gray-700">
{displayCount.toLocaleString()} objects
{objectType.enabled && cacheCount !== undefined && (
<span className="text-xs text-gray-500 ml-1">
({cacheCount.toLocaleString()} cached)
</span>
)}
</div>
<div className="text-xs text-gray-500">
{objectType.attributes.length} attributes
@@ -150,20 +172,21 @@ function ObjectTypeCard({
</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'}
>
{/* Refresh button - only show for enabled types */}
{objectType.enabled && (
<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"
@@ -177,7 +200,8 @@ function ObjectTypeCard({
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>
</button>
)}
<svg
className={`w-5 h-5 text-gray-400 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
fill="none"
@@ -276,6 +300,9 @@ function ObjectTypeCard({
<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-center text-xs font-medium text-gray-500 uppercase tracking-wider">
Position
</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Type
</th>
@@ -504,7 +531,14 @@ export default function DataModelDashboard() {
</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 className="text-sm text-gray-500">
Object Types
{schema.metadata.enabledObjectTypeCount !== undefined && (
<span className="ml-1 text-green-600">
({schema.metadata.enabledObjectTypeCount} enabled)
</span>
)}
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,789 @@
import { useState, useEffect, useCallback } from 'react';
import {
getDataValidationStats,
getDataValidationObjects,
getBrokenReferences,
repairBrokenReferences,
type DataValidationStats,
type DataValidationObjectsResponse,
type BrokenReference,
} from '../services/api';
import PageHeader from './PageHeader';
import ObjectDetailModal from './ObjectDetailModal';
type Tab = 'overview' | 'types' | 'broken-refs';
export default function DataValidationDashboard() {
const [stats, setStats] = useState<DataValidationStats | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<Tab>('overview');
const [selectedType, setSelectedType] = useState<string | null>(null);
const [objects, setObjects] = useState<DataValidationObjectsResponse | null>(null);
const [loadingObjects, setLoadingObjects] = useState(false);
const [brokenRefs, setBrokenRefs] = useState<BrokenReference[]>([]);
const [loadingBrokenRefs, setLoadingBrokenRefs] = useState(false);
const [brokenRefsPage, setBrokenRefsPage] = useState(0);
const [brokenRefsTotal, setBrokenRefsTotal] = useState(0);
const [selectedObjectId, setSelectedObjectId] = useState<string | null>(null);
const [objectHistory, setObjectHistory] = useState<string[]>([]);
const [repairing, setRepairing] = useState(false);
const [repairResult, setRepairResult] = useState<{ repaired: number; deleted: number; failed: number } | null>(null);
const [expandedSchemas, setExpandedSchemas] = useState<Set<string>>(new Set());
const loadStats = useCallback(async () => {
try {
setLoading(true);
setError(null);
const data = await getDataValidationStats();
setStats(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load validation stats');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadStats();
}, [loadStats]);
const loadObjects = useCallback(async (typeName: string, page: number = 0) => {
try {
setLoadingObjects(true);
const limit = 20;
const offset = page * limit;
const data = await getDataValidationObjects(typeName, limit, offset);
setObjects(data);
} catch (err) {
console.error('Failed to load objects:', err);
} finally {
setLoadingObjects(false);
}
}, []);
const loadBrokenReferences = useCallback(async (page: number = 0) => {
try {
setLoadingBrokenRefs(true);
const limit = 50;
const offset = page * limit;
const data = await getBrokenReferences(limit, offset);
setBrokenRefs(data.brokenReferences);
setBrokenRefsTotal(data.pagination.total);
} catch (err) {
console.error('Failed to load broken references:', err);
} finally {
setLoadingBrokenRefs(false);
}
}, []);
useEffect(() => {
if (activeTab === 'broken-refs') {
loadBrokenReferences(brokenRefsPage);
}
}, [activeTab, brokenRefsPage, loadBrokenReferences]);
const handleTypeClick = (typeName: string) => {
setSelectedType(typeName);
setActiveTab('types');
loadObjects(typeName, 0);
};
const handleRepair = async () => {
if (repairing) return;
setRepairing(true);
setRepairResult(null);
try {
const result = await repairBrokenReferences({
mode: 'fetch', // Try to fetch missing objects first, then delete if not found
batchSize: 100,
maxRepairs: 0, // Unlimited
});
setRepairResult({
repaired: result.result.repaired,
deleted: result.result.deleted,
failed: result.result.failed,
});
// Reload stats and broken references
await loadStats();
await loadBrokenReferences(brokenRefsPage);
} catch (err) {
console.error('Failed to repair broken references:', err);
setRepairResult({
repaired: 0,
deleted: 0,
failed: 1,
});
} finally {
setRepairing(false);
}
};
const formatBytes = (bytes: number): string => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
};
const formatDate = (dateString: string | null): string => {
if (!dateString) return 'Nooit';
const date = new Date(dateString);
return date.toLocaleString('nl-NL', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
// Group typeComparisons by schema
const groupedBySchema = (() => {
if (!stats) return new Map<string, typeof stats.comparison.typeComparisons>();
const grouped = new Map<string, typeof stats.comparison.typeComparisons>();
const noSchemaGroup: typeof stats.comparison.typeComparisons = [];
for (const comp of stats.comparison.typeComparisons) {
const schemaKey = comp.schemaId && comp.schemaName
? `${comp.schemaId}|${comp.schemaName}`
: '__NO_SCHEMA__';
if (schemaKey === '__NO_SCHEMA__') {
noSchemaGroup.push(comp);
} else {
if (!grouped.has(schemaKey)) {
grouped.set(schemaKey, []);
}
grouped.get(schemaKey)!.push(comp);
}
}
if (noSchemaGroup.length > 0) {
grouped.set('__NO_SCHEMA__|Geen Schema', noSchemaGroup);
}
return grouped;
})();
const toggleSchemaExpanded = (schemaKey: string) => {
const newExpanded = new Set(expandedSchemas);
if (newExpanded.has(schemaKey)) {
newExpanded.delete(schemaKey);
} else {
newExpanded.add(schemaKey);
}
setExpandedSchemas(newExpanded);
};
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 validatie statistieken...</p>
</div>
</div>
);
}
if (error || !stats) {
return (
<div className="bg-red-50 border border-red-200 rounded-xl p-8 text-center shadow-sm">
<svg className="w-16 h-16 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-xl font-semibold text-red-800 mb-2">Fout bij laden</h3>
<p className="text-red-600 mb-6">{error || 'Geen data beschikbaar'}</p>
<button
onClick={loadStats}
className="px-6 py-3 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-all shadow-md hover:shadow-lg font-medium"
>
Opnieuw proberen
</button>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<PageHeader
title="Data Validatie Dashboard"
description="Overzicht en validatie van data in de database/cache"
icon={
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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>
}
/>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div className="bg-gradient-to-br from-blue-50 to-blue-100 rounded-2xl border border-blue-200 p-6 shadow-md hover:shadow-xl transition-all duration-300 transform hover:-translate-y-1">
<div className="flex items-center justify-between mb-4">
<div className="w-14 h-14 bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl flex items-center justify-center 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="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-3xl font-bold text-gray-900 mb-1">
{typeof stats.cache.totalObjects === 'number'
? stats.cache.totalObjects.toLocaleString('nl-NL')
: Number(stats.cache.totalObjects || 0).toLocaleString('nl-NL')}
</div>
<div className="text-sm font-medium text-blue-700">Objecten in cache</div>
</div>
<div className="bg-gradient-to-br from-emerald-50 to-emerald-100 rounded-2xl border border-emerald-200 p-6 shadow-md hover:shadow-xl transition-all duration-300 transform hover:-translate-y-1">
<div className="flex items-center justify-between mb-4">
<div className="w-14 h-14 bg-gradient-to-br from-emerald-500 to-emerald-600 rounded-xl flex items-center justify-center 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="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-3xl font-bold text-gray-900 mb-1">
{typeof stats.cache.totalRelations === 'number'
? stats.cache.totalRelations.toLocaleString('nl-NL')
: Number(stats.cache.totalRelations || 0).toLocaleString('nl-NL')}
</div>
<div className="text-sm font-medium text-emerald-700">Relaties</div>
</div>
<div className="bg-gradient-to-br from-amber-50 to-amber-100 rounded-2xl border border-amber-200 p-6 shadow-md hover:shadow-xl transition-all duration-300 transform hover:-translate-y-1">
<div className="flex items-center justify-between mb-4">
<div className="w-14 h-14 bg-gradient-to-br from-amber-500 to-amber-600 rounded-xl flex items-center justify-center 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="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
</div>
<div className="text-3xl font-bold text-gray-900 mb-1">
{typeof stats.validation.brokenReferences === 'number'
? stats.validation.brokenReferences.toLocaleString('nl-NL')
: Number(stats.validation.brokenReferences || 0).toLocaleString('nl-NL')}
</div>
<div className="text-sm font-medium text-amber-700">Kapotte referenties</div>
</div>
<div className="bg-gradient-to-br from-purple-50 to-purple-100 rounded-2xl border border-purple-200 p-6 shadow-md hover:shadow-xl transition-all duration-300 transform hover:-translate-y-1">
<div className="flex items-center justify-between mb-4">
<div className="w-14 h-14 bg-gradient-to-br from-purple-500 to-purple-600 rounded-xl flex items-center justify-center 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="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
</div>
<div className="text-2xl font-bold text-gray-900 mb-1">{formatBytes(stats.cache.dbSizeBytes)}</div>
<div className="text-sm font-medium text-purple-700">Database grootte</div>
</div>
</div>
{/* Sync Status */}
<div className="bg-white rounded-2xl border border-gray-200 p-6 shadow-md">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 bg-gradient-to-br from-indigo-500 to-indigo-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 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>
</div>
<h2 className="text-xl font-semibold text-gray-900">Sync Status</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="bg-gray-50 rounded-xl p-4 border border-gray-100">
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">Laatste volledige sync</div>
<div className="text-base font-semibold text-gray-900">{formatDate(stats.cache.lastFullSync)}</div>
</div>
<div className="bg-gray-50 rounded-xl p-4 border border-gray-100">
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">Laatste incrementele sync</div>
<div className="text-base font-semibold text-gray-900">{formatDate(stats.cache.lastIncrementalSync)}</div>
</div>
</div>
</div>
{/* Tabs */}
<div className="bg-white rounded-2xl border border-gray-200 shadow-md overflow-hidden">
<div className="border-b border-gray-200 bg-gradient-to-r from-gray-50 to-white">
<nav className="flex -mb-px">
<button
onClick={() => setActiveTab('overview')}
className={`px-6 py-4 text-sm font-semibold border-b-3 transition-all relative ${
activeTab === 'overview'
? 'border-blue-500 text-blue-600 bg-blue-50/50'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:bg-gray-50'
}`}
>
Overzicht
{activeTab === 'overview' && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-gradient-to-r from-blue-500 to-blue-600"></div>
)}
</button>
<button
onClick={() => setActiveTab('types')}
className={`px-6 py-4 text-sm font-semibold border-b-3 transition-all relative ${
activeTab === 'types'
? 'border-blue-500 text-blue-600 bg-blue-50/50'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:bg-gray-50'
}`}
>
Object Types
<span className={`ml-2 px-2 py-0.5 rounded-full text-xs font-medium ${
activeTab === 'types' ? 'bg-blue-100 text-blue-700' : 'bg-gray-200 text-gray-600'
}`}>
{stats.comparison.typeComparisons.length}
</span>
{activeTab === 'types' && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-gradient-to-r from-blue-500 to-blue-600"></div>
)}
</button>
<button
onClick={() => setActiveTab('broken-refs')}
className={`px-6 py-4 text-sm font-semibold border-b-3 transition-all relative ${
activeTab === 'broken-refs'
? 'border-blue-500 text-blue-600 bg-blue-50/50'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:bg-gray-50'
}`}
>
Kapotte Referenties
<span className={`ml-2 px-2 py-0.5 rounded-full text-xs font-medium ${
activeTab === 'broken-refs' ? 'bg-blue-100 text-blue-700' : 'bg-gray-200 text-gray-600'
}`}>
{stats.validation.brokenReferences}
</span>
{activeTab === 'broken-refs' && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-gradient-to-r from-blue-500 to-blue-600"></div>
)}
</button>
</nav>
</div>
<div className="p-6">
{/* Overview Tab */}
{activeTab === 'overview' && (
<div className="space-y-6">
<h3 className="text-xl font-semibold text-gray-900">Sync Status per Type</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-gradient-to-br from-emerald-50 to-emerald-100 border border-emerald-200 rounded-xl p-5 shadow-sm">
<div className="text-3xl font-bold text-emerald-700 mb-2">{stats.comparison.totalSynced}</div>
<div className="text-sm font-semibold text-emerald-600">Gesynchroniseerd</div>
</div>
<div className="bg-gradient-to-br from-amber-50 to-amber-100 border border-amber-200 rounded-xl p-5 shadow-sm">
<div className="text-3xl font-bold text-amber-700 mb-2">{stats.comparison.totalOutdated}</div>
<div className="text-sm font-semibold text-amber-600">Verouderd</div>
</div>
<div className="bg-gradient-to-br from-red-50 to-red-100 border border-red-200 rounded-xl p-5 shadow-sm">
<div className="text-3xl font-bold text-red-700 mb-2">{stats.comparison.totalMissing}</div>
<div className="text-sm font-semibold text-red-600">Niet gesynchroniseerd</div>
</div>
</div>
{/* Grouped by Schema */}
<div className="space-y-4">
{Array.from(groupedBySchema.entries()).map(([schemaKey, typeComps]) => {
const [schemaId, schemaName] = schemaKey.split('|');
const isExpanded = expandedSchemas.has(schemaKey);
const syncedCount = typeComps.filter(t => t.syncStatus === 'synced').length;
const outdatedCount = typeComps.filter(t => t.syncStatus === 'outdated').length;
const missingCount = typeComps.filter(t => t.syncStatus === 'missing').length;
return (
<div key={schemaKey} className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden hover:shadow-md transition-shadow">
{/* Schema Header */}
<div
className="px-6 py-4 bg-gradient-to-r from-gray-50 via-white to-gray-50 border-b border-gray-200 cursor-pointer hover:from-gray-100 hover:via-gray-50 hover:to-gray-100 transition-all"
onClick={() => toggleSchemaExpanded(schemaKey)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<button
onClick={(e) => {
e.stopPropagation();
toggleSchemaExpanded(schemaKey);
}}
className="text-gray-400 hover:text-gray-600 transition-colors p-1 rounded hover:bg-gray-200"
>
<svg
className={`w-5 h-5 transition-transform duration-200 ${isExpanded ? 'rotate-90' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-indigo-100 flex items-center justify-center">
<svg className="w-5 h-5 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<div>
<h3 className="text-base font-semibold text-gray-900">{schemaName}</h3>
<p className="text-xs text-gray-500 mt-0.5">ID: {schemaId === '__NO_SCHEMA__' ? 'N/A' : schemaId} {typeComps.length} types</p>
</div>
</div>
</div>
<div className="flex items-center gap-4">
<div className="text-right">
<div className="text-sm font-semibold text-gray-900">
<span className="text-emerald-600">{syncedCount}</span>
<span className="text-gray-400 mx-1">/</span>
<span className="text-amber-600">{outdatedCount}</span>
<span className="text-gray-400 mx-1">/</span>
<span className="text-red-600">{missingCount}</span>
</div>
<div className="text-xs text-gray-500">gesynchroniseerd / verouderd / ontbreekt</div>
</div>
</div>
</div>
</div>
{/* Object Types List */}
{isExpanded && (
<div className="divide-y divide-gray-100">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gradient-to-r from-gray-50 to-gray-100">
<tr>
<th className="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">
Type
</th>
<th className="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">
Cache
</th>
<th className="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">
Jira
</th>
<th className="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">
Verschil
</th>
<th className="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">
Status
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-100">
{typeComps.map((comp) => (
<tr
key={comp.typeName}
className="hover:bg-blue-50/50 cursor-pointer transition-colors group"
onClick={() => handleTypeClick(comp.typeName)}
>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-semibold text-gray-900 group-hover:text-blue-600 transition-colors">{comp.typeDisplayName}</div>
<div className="text-xs text-gray-500 font-mono mt-1">{comp.typeName}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{comp.cacheCount.toLocaleString()}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{comp.jiraCount.toLocaleString()}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
{comp.difference !== 0 && (
<span className={`font-semibold ${comp.difference > 0 ? 'text-amber-600' : 'text-emerald-600'}`}>
{comp.difference > 0 ? '+' : ''}{comp.difference.toLocaleString()}
</span>
)}
{comp.difference === 0 && <span className="text-gray-400"></span>}
</td>
<td className="px-6 py-4 whitespace-nowrap">
{comp.syncStatus === 'synced' && (
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-emerald-100 text-emerald-700 border border-emerald-200">
Gesynchroniseerd
</span>
)}
{comp.syncStatus === 'outdated' && (
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-amber-100 text-amber-700 border border-amber-200">
Verouderd
</span>
)}
{comp.syncStatus === 'missing' && (
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-red-100 text-red-700 border border-red-200">
Niet gesynchroniseerd
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
);
})}
</div>
</div>
)}
{/* Types Tab */}
{activeTab === 'types' && (
<div>
{!selectedType ? (
<div className="text-center py-16 text-gray-500">
<svg className="w-16 h-16 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 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<p className="text-lg font-medium">Selecteer een type uit de overzicht tab om objecten te bekijken</p>
</div>
) : (
<div className="space-y-6">
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 rounded-xl p-5 border border-blue-100">
<h3 className="text-xl font-bold text-gray-900 mb-1">
{objects?.typeDisplayName || selectedType}
</h3>
<p className="text-sm text-gray-600 font-mono">{selectedType}</p>
</div>
{loadingObjects ? (
<div className="text-center py-12">
<div className="w-10 h-10 border-4 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
<p className="text-gray-500 font-medium">Laden...</p>
</div>
) : objects ? (
<div className="space-y-4">
<div className="text-sm font-medium text-gray-600">
{objects.pagination.total.toLocaleString()} objecten totaal
</div>
<div className="overflow-x-auto rounded-xl border border-gray-200 shadow-sm">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gradient-to-r from-gray-50 to-gray-100">
<tr>
<th className="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">
ID
</th>
<th className="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">
Key
</th>
<th className="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">
Label
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-100">
{objects.objects.map((obj: any) => (
<tr
key={obj.id}
className="hover:bg-blue-50/50 cursor-pointer transition-colors group"
onClick={() => setSelectedObjectId(obj.id)}
>
<td className="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-600 group-hover:text-blue-600 transition-colors">
{obj.id}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-semibold text-gray-900 group-hover:text-blue-600 transition-colors">
{obj.objectKey}
</td>
<td className="px-6 py-4 text-sm font-medium text-gray-900 group-hover:text-blue-600 transition-colors">{obj.label}</td>
</tr>
))}
</tbody>
</table>
</div>
{objects.pagination.hasMore && (
<div className="text-center pt-4">
<button
onClick={() => loadObjects(selectedType, Math.floor((objects.pagination.offset + objects.pagination.limit) / objects.pagination.limit))}
className="px-6 py-3 text-sm font-semibold text-blue-600 hover:text-blue-700 hover:bg-blue-50 rounded-lg transition-all border border-blue-200"
>
Meer laden...
</button>
</div>
)}
</div>
) : (
<div className="text-center py-12 text-gray-500">
<svg className="w-16 h-16 mx-auto mb-4 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
</svg>
<p className="text-lg font-medium">Geen objecten gevonden</p>
</div>
)}
</div>
)}
</div>
)}
{/* Broken References Tab */}
{activeTab === 'broken-refs' && (
<div className="space-y-6">
<div className="bg-gradient-to-r from-amber-50 to-red-50 rounded-xl p-5 border border-amber-200">
<div className="flex items-center justify-between mb-2">
<div>
<h3 className="text-xl font-bold text-gray-900 mb-2">Kapotte Referenties</h3>
<p className="text-sm text-gray-600">
Referenties naar objecten die niet in de cache aanwezig zijn ({brokenRefsTotal.toLocaleString()} totaal)
</p>
</div>
{brokenRefsTotal > 0 && (
<button
onClick={handleRepair}
disabled={repairing}
className="px-6 py-3 bg-gradient-to-r from-blue-600 to-blue-700 text-white rounded-xl hover:from-blue-700 hover:to-blue-800 transition-all shadow-lg hover:shadow-xl font-semibold disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{repairing ? (
<>
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
Repareren...
</>
) : (
<>
<svg className="w-5 h-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>
Repareer Referenties
</>
)}
</button>
)}
</div>
{repairResult && (
<div className="mt-4 p-4 bg-white rounded-lg border border-gray-200">
<div className="flex items-center gap-4 text-sm">
{repairResult.repaired > 0 && (
<div className="flex items-center gap-2 text-emerald-700">
<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-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="font-semibold">{repairResult.repaired} gerepareerd</span>
</div>
)}
{repairResult.deleted > 0 && (
<div className="flex items-center gap-2 text-amber-700">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
<span className="font-semibold">{repairResult.deleted} verwijderd</span>
</div>
)}
{repairResult.failed > 0 && (
<div className="flex items-center gap-2 text-red-700">
<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>
<span className="font-semibold">{repairResult.failed} mislukt</span>
</div>
)}
</div>
</div>
)}
</div>
{loadingBrokenRefs ? (
<div className="text-center py-12">
<div className="w-10 h-10 border-4 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
<p className="text-gray-500 font-medium">Laden...</p>
</div>
) : brokenRefs.length > 0 ? (
<div className="space-y-4">
<div className="overflow-x-auto rounded-xl border border-gray-200 shadow-sm">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gradient-to-r from-gray-50 to-gray-100">
<tr>
<th className="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">
Object
</th>
<th className="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">
Type
</th>
<th className="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">
Attribuut
</th>
<th className="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">
Referentie ID
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-100">
{brokenRefs.map((ref, idx) => (
<tr
key={idx}
className="hover:bg-red-50/50 cursor-pointer transition-colors group"
onClick={() => setSelectedObjectId(ref.object_id)}
>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-semibold text-gray-900 group-hover:text-red-600 transition-colors">{ref.label}</div>
<div className="text-xs text-gray-500 font-mono mt-1">{ref.object_key}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{ref.object_type_name}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-700">
{ref.field_name}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-mono">
<span className="text-red-600 font-semibold" title="Kapotte referentie - object bestaat niet">
{ref.reference_object_id}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
{brokenRefsTotal > (brokenRefsPage + 1) * 50 && (
<div className="text-center pt-4">
<button
onClick={() => setBrokenRefsPage(brokenRefsPage + 1)}
className="px-6 py-3 text-sm font-semibold text-blue-600 hover:text-blue-700 hover:bg-blue-50 rounded-lg transition-all border border-blue-200"
>
Meer laden...
</button>
</div>
)}
</div>
) : (
<div className="text-center py-16 text-gray-500">
<svg className="w-20 h-20 mx-auto mb-4 text-emerald-300" 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-lg font-semibold text-gray-600">Geen kapotte referenties gevonden</p>
<p className="text-sm text-gray-500 mt-2">Alle referenties zijn geldig!</p>
</div>
)}
</div>
)}
</div>
</div>
{/* Object Detail Modal */}
<ObjectDetailModal
objectId={selectedObjectId}
onClose={() => {
setSelectedObjectId(null);
setObjectHistory([]);
}}
onObjectClick={(objectId) => {
// Add current object to history before navigating
if (selectedObjectId && selectedObjectId !== objectId) {
setObjectHistory(prev => {
// Only add if it's not already the last item
if (prev.length === 0 || prev[prev.length - 1] !== selectedObjectId) {
return [...prev, selectedObjectId];
}
return prev;
});
}
setSelectedObjectId(objectId);
}}
onBack={() => {
if (objectHistory.length > 0) {
const previousObjectId = objectHistory[objectHistory.length - 1];
setObjectHistory(prev => prev.slice(0, -1));
setSelectedObjectId(previousObjectId);
}
}}
canGoBack={objectHistory.length > 0}
/>
</div>
);
}

View File

@@ -55,8 +55,13 @@ export function EffortDisplay({
const dynamicsFactor = breakdown?.dynamicsFactor ?? { value: 1.0, name: null };
const complexityFactor = breakdown?.complexityFactor ?? { value: 1.0, name: null };
// Ensure factor values are numbers (convert if needed)
const numberOfUsersValue = typeof numberOfUsersFactor?.value === 'number' ? numberOfUsersFactor.value : parseFloat(String(numberOfUsersFactor?.value ?? 1.0)) || 1.0;
const dynamicsValue = typeof dynamicsFactor?.value === 'number' ? dynamicsFactor.value : parseFloat(String(dynamicsFactor?.value ?? 1.0)) || 1.0;
const complexityValue = typeof complexityFactor?.value === 'number' ? complexityFactor.value : parseFloat(String(complexityFactor?.value ?? 1.0)) || 1.0;
// Calculate final min/max FTE by applying factors to base min/max
const factorMultiplier = numberOfUsersFactor.value * dynamicsFactor.value * complexityFactor.value;
const factorMultiplier = numberOfUsersValue * dynamicsValue * complexityValue;
const finalMinFTE = baseEffortMin !== null ? baseEffortMin * factorMultiplier : null;
const finalMaxFTE = baseEffortMax !== null ? baseEffortMax * factorMultiplier : null;
@@ -224,16 +229,16 @@ export function EffortDisplay({
{/* 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})`}
Number of Users: × {numberOfUsersValue.toFixed(2)}
{numberOfUsersFactor?.name && ` (${numberOfUsersFactor.name})`}
</div>
<div>
Dynamics Factor: × {dynamicsFactor.value.toFixed(2)}
{dynamicsFactor.name && ` (${dynamicsFactor.name})`}
Dynamics Factor: × {dynamicsValue.toFixed(2)}
{dynamicsFactor?.name && ` (${dynamicsFactor.name})`}
</div>
<div>
Complexity Factor: × {complexityFactor.value.toFixed(2)}
{complexityFactor.name && ` (${complexityFactor.name})`}
Complexity Factor: × {complexityValue.toFixed(2)}
{complexityFactor?.name && ` (${complexityFactor.name})`}
</div>
{/* Hours breakdown */}

View File

@@ -138,199 +138,370 @@ export default function FTECalculator() {
});
}, [numberOfUsers]);
// Calculate completion percentage
const completionCount = [
selectedGovernanceModel,
selectedApplicationType,
selectedBusinessImpactAnalyse,
selectedHosting,
selectedNumberOfUsers,
selectedDynamicsFactor,
selectedComplexityFactor,
].filter(Boolean).length;
const totalFields = 7;
const completionPercentage = Math.round((completionCount / totalFields) * 100);
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 className="flex flex-col items-center justify-center h-64 space-y-4">
<div className="animate-spin rounded-full h-12 w-12 border-4 border-blue-600 border-t-transparent" />
<p className="text-gray-600 font-medium">Laden van referentiedata...</p>
</div>
);
}
if (error) {
return (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-red-700">
{error}
<div className="bg-red-50 border-l-4 border-red-500 rounded-lg p-6 shadow-sm">
<div className="flex items-start">
<div className="flex-shrink-0">
<svg className="h-6 w-6 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div className="ml-3">
<h3 className="text-sm font-semibold text-red-800 mb-1">Fout bij laden</h3>
<p className="text-sm text-red-700">{error}</p>
</div>
</div>
</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 className="space-y-6 max-w-7xl">
{/* Header with gradient */}
<div className="bg-gradient-to-r from-blue-600 to-blue-700 rounded-xl shadow-lg p-8 text-white">
<div className="flex items-start justify-between">
<div className="flex items-center gap-4">
<div className="bg-white/20 rounded-lg p-3 backdrop-blur-sm">
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
</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}
/>
<h1 className="text-3xl font-bold mb-2">FTE Calculator</h1>
<p className="text-blue-100 text-lg">
Bereken de benodigde FTE voor applicatiemanagement op basis van classificatievelden
</p>
</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>
{/* Progress indicator */}
<div className="mt-6 pt-6 border-t border-blue-500/30">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-blue-100">Voortgang</span>
<span className="text-sm font-semibold">{completionCount} van {totalFields} velden ingevuld ({completionPercentage}%)</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 className="w-full bg-blue-500/30 rounded-full h-2.5">
<div
className="bg-white rounded-full h-2.5 transition-all duration-500 ease-out"
style={{ width: `${completionPercentage}%` }}
/>
</div>
)}
</div>
</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 className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Form Section - 2 columns */}
<div className="lg:col-span-2 space-y-6">
{/* Application Classification Card */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<div className="bg-gray-50 border-b border-gray-200 px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<svg className="w-5 h-5 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
<h2 className="text-lg font-semibold text-gray-900">Applicatie Classificatie</h2>
</div>
<button
onClick={handleReset}
className="px-3 py-1.5 text-sm font-medium text-gray-700 bg-white hover:bg-gray-100 border border-gray-300 rounded-lg transition-all duration-200 flex items-center gap-1.5 shadow-sm hover:shadow"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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>
Reset
</button>
</div>
</div>
<div className="p-6 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-semibold text-gray-700 mb-2 flex items-center gap-1">
Application Type
{selectedApplicationType && (
<span className="inline-flex items-center justify-center w-5 h-5 bg-green-100 text-green-600 rounded-full text-xs font-bold">
</span>
)}
</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}
className="shadow-sm"
/>
</div>
{/* Hosting */}
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2 flex items-center gap-1">
Hosting
{selectedHosting && (
<span className="inline-flex items-center justify-center w-5 h-5 bg-green-100 text-green-600 rounded-full text-xs font-bold">
</span>
)}
</label>
<CustomSelect
value={selectedHosting?.objectId || ''}
onChange={(value) => {
const selected = applicationManagementHosting.find((h) => h.objectId === value);
setSelectedHosting(selected || null);
}}
options={applicationManagementHosting}
placeholder="Selecteer Hosting..."
className="shadow-sm"
/>
</div>
</div>
{/* Second row: Business Impact Analyse - Full width */}
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2 flex items-center gap-1">
Business Impact Analyse
{selectedBusinessImpactAnalyse && (
<span className="inline-flex items-center justify-center w-5 h-5 bg-green-100 text-green-600 rounded-full text-xs font-bold">
</span>
)}
</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}
className="shadow-sm"
/>
</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-semibold text-gray-700 mb-2 flex items-center gap-1">
Number of Users
{selectedNumberOfUsers && (
<span className="inline-flex items-center justify-center w-5 h-5 bg-green-100 text-green-600 rounded-full text-xs font-bold">
</span>
)}
</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}
className="shadow-sm"
/>
</div>
{/* Dynamics Factor */}
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2 flex items-center gap-1">
Dynamics Factor
{selectedDynamicsFactor && (
<span className="inline-flex items-center justify-center w-5 h-5 bg-green-100 text-green-600 rounded-full text-xs font-bold">
</span>
)}
</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}
className="shadow-sm"
/>
</div>
{/* Complexity Factor */}
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2 flex items-center gap-1">
Complexity Factor
{selectedComplexityFactor && (
<span className="inline-flex items-center justify-center w-5 h-5 bg-green-100 text-green-600 rounded-full text-xs font-bold">
</span>
)}
</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}
className="shadow-sm"
/>
</div>
</div>
</div>
{/* ICT Governance Model - Required field with emphasis */}
<div className="px-6 py-6 bg-blue-50 border-t border-gray-200">
<label className="block text-sm font-semibold text-gray-900 mb-3 flex items-center gap-2">
<span className="inline-flex items-center justify-center w-6 h-6 bg-red-500 text-white rounded-full text-xs font-bold">
*
</span>
ICT Governance Model
<span className="text-xs font-normal text-gray-500">(verplicht)</span>
{selectedGovernanceModel && (
<span className="inline-flex items-center justify-center w-5 h-5 bg-green-500 text-white rounded-full text-xs font-bold ml-auto">
</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}
className="shadow-sm"
/>
{!selectedGovernanceModel && (
<p className="mt-2 text-xs text-gray-500 flex items-center gap-1">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Dit veld is verplicht om een berekening uit te voeren
</p>
)}
</div>
</div>
</div>
{/* Result Section - 1 column, sticky */}
<div className="lg:col-span-1">
<div className={`bg-white rounded-xl shadow-lg border-2 transition-all duration-300 ${
calculatedFte !== null && calculatedFte !== undefined
? 'border-blue-500 bg-gradient-to-br from-blue-50 to-white'
: 'border-gray-200'
}`}>
<div className={`px-6 py-4 border-b-2 ${
calculatedFte !== null && calculatedFte !== undefined
? 'border-blue-500 bg-blue-600'
: 'border-gray-200 bg-gray-50'
}`}>
<div className="flex items-center gap-2">
<svg className={`w-6 h-6 ${
calculatedFte !== null && calculatedFte !== undefined ? 'text-white' : 'text-gray-400'
}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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>
<h2 className={`text-lg font-bold ${
calculatedFte !== null && calculatedFte !== undefined ? 'text-white' : 'text-gray-900'
}`}>
Berekening Resultaat
</h2>
</div>
</div>
<div className="p-6">
{isCalculating ? (
<div className="flex flex-col items-center justify-center py-8 space-y-3">
<div className="animate-spin rounded-full h-10 w-10 border-4 border-blue-600 border-t-transparent" />
<span className="text-gray-600 font-medium">Berekenen...</span>
</div>
) : calculatedFte !== null && calculatedFte !== undefined ? (
<div className="space-y-4">
<div className="bg-white rounded-lg border-2 border-blue-200 p-4 shadow-sm">
<EffortDisplay
effectiveFte={getEffectiveFte(calculatedFte, null, null)}
calculatedFte={calculatedFte ?? null}
overrideFte={null}
breakdown={effortBreakdown}
isPreview={true}
showDetails={true}
showOverrideInput={false}
/>
</div>
<div className="bg-green-50 border border-green-200 rounded-lg p-3">
<div className="flex items-start gap-2">
<svg className="w-5 h-5 text-green-600 mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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-sm text-green-800 font-medium">
Berekening voltooid op basis van de ingevulde velden
</p>
</div>
</div>
</div>
) : (
<div className="text-center py-8">
<div className="mx-auto w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mb-4">
<svg className="w-8 h-8 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<p className="text-gray-600 font-medium mb-1">Nog geen berekening</p>
<p className="text-sm text-gray-500">
Vul de classificatievelden in om een FTE berekening uit te voeren
</p>
{!selectedGovernanceModel && (
<div className="mt-4 p-3 bg-amber-50 border border-amber-200 rounded-lg">
<p className="text-xs text-amber-800 flex items-center justify-center gap-1">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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>
Selecteer minimaal het ICT Governance Model
</p>
</div>
)}
</div>
)}
</div>
</div>
</div>
</div>
</div>
);

View File

@@ -1,4 +1,5 @@
import { useState, useEffect } from 'react';
import PageHeader from './PageHeader';
import { Link } from 'react-router-dom';
interface FunctionFTE {
@@ -132,12 +133,15 @@ export default function FTEPerZiRADomain() {
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-gray-900">FTE per ZiRA Domain</h1>
<p className="mt-1 text-gray-500">
Welke business domeinen vereisen het meeste IT management effort?
</p>
</div>
<PageHeader
title="FTE per ZiRA Domain"
description="Welke business domeinen vereisen het meeste IT management effort?"
icon={
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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>
}
/>
{/* Overall Statistics */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">

View File

@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import PageHeader from './PageHeader';
interface ApplicationWithIssues {
id: string;
@@ -122,25 +123,32 @@ export default function GovernanceAnalysis() {
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">
<PageHeader
title="Analyse Regiemodel"
description={
<>
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" />
<span className="text-blue-100 text-sm">Standaard worden Closed en Deprecated applicaties uitgesloten.</span>
</>
}
icon={
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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>
Terug naar rapporten
</Link>
</div>
}
actions={
<Link
to="/reports"
className="px-4 py-2 bg-white/20 hover:bg-white/30 backdrop-blur-sm rounded-lg transition-colors flex items-center gap-2 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 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Terug naar rapporten
</Link>
}
/>
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">

View File

@@ -25,6 +25,7 @@ import { StatusBadge, BusinessImportanceBadge } from './ApplicationList';
import CustomSelect from './CustomSelect';
import { EffortDisplay } from './EffortDisplay';
import { useEffortCalculation, getEffectiveFte } from '../hooks/useEffortCalculation';
import PageHeader from './PageHeader';
import type {
ApplicationDetails,
AISuggestion,
@@ -259,7 +260,17 @@ export default function GovernanceModelHelper() {
setOverrideFTE(app.overrideFTE ?? null);
// Note: Calculated effort is automatically reset by useEffortCalculation hook when application changes
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load application');
console.error('Failed to load application:', err);
let errorMessage = 'Failed to load application';
if (err instanceof Error) {
errorMessage = err.message;
} else if (typeof err === 'object' && err !== null && 'error' in err) {
errorMessage = String(err.error);
if ('details' in err) {
errorMessage += `: ${err.details}`;
}
}
setError(errorMessage);
} finally {
setLoading(false);
}
@@ -927,55 +938,93 @@ export default function GovernanceModelHelper() {
/>
)}
{/* Navigation header */}
<div className="flex justify-between items-center">
<div className="flex items-center gap-4">
<Link
to={`/application/${id}`}
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"
{/* Page Header */}
<PageHeader
title={application.name || 'Applicatie bewerken'}
description={
<div className="flex flex-wrap items-center gap-2">
{application.key && (
<span className="text-blue-100 text-sm font-mono bg-white/10 px-2 py-1 rounded">
{application.key}
</span>
)}
{application.status && (
<StatusBadge status={application.status} variant="header" />
)}
{applicationIds.length > 0 && (
<span className="text-blue-100 text-sm">
Applicatie {currentIndex + 1} van {applicationIds.length}
</span>
)}
</div>
}
icon={
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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>
}
actions={
<div className="flex items-center gap-2">
<Link
to={`/application/${id}`}
className="px-4 py-2 bg-white/20 hover:bg-white/30 backdrop-blur-sm rounded-lg transition-colors flex items-center gap-2 text-sm font-medium"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 19l-7-7 7-7"
/>
</svg>
Terug
</Link>
<span className="text-gray-300">|</span>
<Link
to="/application/overview"
className="flex items-center text-blue-600 hover:text-blue-800"
>
<svg
className="w-4 h-4 mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
<svg
className="w-4 h-4"
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
</Link>
<Link
to="/application/overview"
className="px-4 py-2 bg-white/20 hover:bg-white/30 backdrop-blur-sm rounded-lg transition-colors flex items-center gap-2 text-sm font-medium"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6h16M4 10h16M4 14h16M4 18h16"
/>
</svg>
Overzicht
</Link>
</div>
{applicationIds.length > 0 && (
<span className="text-sm text-gray-500">
Applicatie {currentIndex + 1} van {applicationIds.length}
</span>
)}
</div>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6h16M4 10h16M4 14h16M4 18h16"
/>
</svg>
Overzicht
</Link>
{jiraHost && application.key && (
<a
href={`${jiraHost}/secure/insight/assets/${application.key}`}
target="_blank"
rel="noopener noreferrer"
className="px-4 py-2 bg-white/20 hover:bg-white/30 backdrop-blur-sm rounded-lg transition-colors flex items-center gap-2 text-sm font-medium"
title="Openen in Jira Assets"
>
<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>
Jira
</a>
)}
</div>
}
/>
{/* Application Info (Read-only) */}
<div className="card">

View File

@@ -102,19 +102,15 @@ export default function LifecyclePipeline() {
return (
<div>
{/* Header */}
<div className="mb-8">
<div className="flex items-center gap-3 mb-2">
<div className="p-2 bg-blue-100 rounded-lg">
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
</svg>
</div>
<h1 className="text-3xl font-bold text-gray-900">Lifecycle Pipeline</h1>
</div>
<p className="text-gray-600">
Overzicht van applicaties verdeeld over de verschillende lifecycle fases en statussen
</p>
</div>
<PageHeader
title="Lifecycle Pipeline"
description="Overzicht van applicaties verdeeld over de verschillende lifecycle fases en statussen"
icon={
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
</svg>
}
/>
{/* Total Summary Card */}
<div className="bg-gradient-to-r from-slate-700 to-slate-800 rounded-xl shadow-lg p-6 mb-6 text-white">

View File

@@ -0,0 +1,342 @@
import { useState, useEffect } from 'react';
import { getDataValidationObject, type DataValidationObjectResponse } from '../services/api';
import { useAuthStore } from '../stores/authStore';
interface ObjectDetailModalProps {
objectId: string | null;
onClose: () => void;
onObjectClick?: (objectId: string) => void;
onBack?: () => void;
canGoBack?: boolean;
}
interface ObjectReference {
objectId: string;
objectKey: string;
label: string;
}
function isObjectReference(value: any): value is ObjectReference {
return value && typeof value === 'object' && 'objectId' in value;
}
function formatValue(value: any): string {
if (value === null || value === undefined) return '—';
if (typeof value === 'boolean') return value ? 'Ja' : 'Nee';
if (typeof value === 'object') {
if (Array.isArray(value)) {
if (value.length === 0) return '—';
return value.map(v => formatValue(v)).join(', ');
}
if (isObjectReference(value)) {
return value.label || value.objectKey || value.objectId;
}
return JSON.stringify(value);
}
return String(value);
}
export default function ObjectDetailModal({ objectId, onClose, onObjectClick, onBack, canGoBack = false }: ObjectDetailModalProps) {
const [objectData, setObjectData] = useState<DataValidationObjectResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const { config } = useAuthStore();
useEffect(() => {
if (!objectId) {
setObjectData(null);
return;
}
const loadObject = async () => {
setLoading(true);
setError(null);
try {
const data = await getDataValidationObject(objectId);
setObjectData(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load object');
} finally {
setLoading(false);
}
};
loadObject();
}, [objectId]);
if (!objectId) return null;
const handleReferenceClick = (ref: ObjectReference, e: React.MouseEvent) => {
e.stopPropagation();
if (onObjectClick) {
onObjectClick(ref.objectId);
}
};
const handleBack = () => {
if (onBack) {
onBack();
}
};
const renderAttributeValue = (key: string, value: any) => {
// Skip internal/system fields
if (key.startsWith('_')) return null;
// Handle reference fields
if (isObjectReference(value)) {
return (
<button
onClick={(e) => handleReferenceClick(value, e)}
className="inline-flex items-center gap-1.5 text-sm font-medium text-blue-600 hover:text-blue-800 hover:underline transition-colors"
>
<svg className="w-4 h-4" 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>
{value.label || value.objectKey || value.objectId}
</button>
);
}
// Handle arrays of references
if (Array.isArray(value) && value.length > 0 && isObjectReference(value[0])) {
return (
<div className="flex flex-wrap gap-3">
{value.map((ref, idx) => (
<button
key={idx}
onClick={(e) => handleReferenceClick(ref, e)}
className="inline-flex items-center gap-1.5 text-sm font-medium text-blue-600 hover:text-blue-800 hover:underline transition-colors"
>
<svg className="w-4 h-4" 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>
{ref.label || ref.objectKey || ref.objectId}
</button>
))}
</div>
);
}
// Regular values
return <span className="text-gray-900 font-medium">{formatValue(value)}</span>;
};
return (
<div className="fixed inset-0 z-50 overflow-y-auto">
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/70 backdrop-blur-sm transition-opacity animate-in fade-in"
onClick={onClose}
/>
{/* Modal */}
<div className="flex min-h-full items-center justify-center p-4">
<div
className="relative bg-white rounded-2xl shadow-2xl border border-gray-200 max-w-5xl w-full max-h-[90vh] overflow-hidden flex flex-col animate-in slide-in-from-bottom-4 duration-300"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="px-8 py-6 border-b border-gray-200 bg-gradient-to-r from-gray-50 to-white flex items-center justify-between">
<div className="flex items-center gap-4 flex-1">
{/* Back Button */}
{canGoBack && onBack && (
<button
onClick={handleBack}
className="p-2.5 text-gray-600 hover:text-gray-900 hover:bg-gray-200 rounded-xl transition-all hover:shadow-md"
title="Terug naar vorig object"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
)}
<div className="flex-1">
{loading ? (
<div className="flex items-center gap-3">
<div className="w-5 h-5 border-2 border-blue-600 border-t-transparent rounded-full animate-spin" />
<h2 className="text-xl font-bold text-gray-900">Laden...</h2>
</div>
) : objectData ? (
<div>
<div className="flex items-center gap-3 mb-2">
<div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl flex items-center justify-center shadow-lg">
<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="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<h2 className="text-xl font-bold text-gray-900">{objectData.metadata.label}</h2>
</div>
<div className="flex items-center gap-4 mt-1 text-sm text-gray-600">
<span className="font-mono bg-gray-100 px-2 py-1 rounded-md">{objectData.metadata.objectKey}</span>
<span className="text-gray-300"></span>
<span className="font-semibold">{objectData.metadata.typeDisplayName}</span>
</div>
</div>
) : (
<h2 className="text-xl font-bold text-gray-900">Object Details</h2>
)}
</div>
</div>
<div className="flex items-center gap-2">
{/* Jira Assets Link */}
{objectData && config?.jiraHost && (
<a
href={`${config.jiraHost}/secure/insight/assets/${objectData.metadata.objectKey}`}
target="_blank"
rel="noopener noreferrer"
className="p-2.5 text-blue-600 hover:text-blue-700 hover:bg-blue-50 rounded-xl transition-all"
title="Open in Jira Assets"
>
<svg className="w-5 h-5" 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>
)}
<button
onClick={onClose}
className="p-2.5 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-xl transition-all"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto px-8 py-6 bg-gray-50/30">
{error ? (
<div className="bg-red-50 border-2 border-red-200 rounded-xl p-6 text-center">
<svg className="w-16 h-16 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>
<p className="text-red-600 font-semibold">{error}</p>
</div>
) : objectData ? (
<div className="space-y-6">
{/* Basic Info */}
<div>
<h3 className="text-sm font-bold text-gray-700 uppercase tracking-wider mb-4 flex items-center gap-2">
<div className="w-1 h-5 bg-gradient-to-b from-blue-500 to-blue-600 rounded-full"></div>
Basis Informatie
</h3>
<div className="bg-white rounded-xl border border-gray-200 p-6 shadow-sm">
<div className="grid grid-cols-2 gap-6">
<div>
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">ID</div>
<div className="text-sm font-mono text-gray-900 bg-gray-50 px-3 py-2 rounded-lg border border-gray-200">{objectData.metadata.objectKey}</div>
</div>
<div>
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">Type</div>
<div className="text-sm font-semibold text-gray-900 bg-gray-50 px-3 py-2 rounded-lg border border-gray-200">{objectData.metadata.typeDisplayName}</div>
</div>
</div>
</div>
</div>
{/* Attributes */}
<div>
<h3 className="text-sm font-bold text-gray-700 uppercase tracking-wider mb-4 flex items-center gap-2">
<div className="w-1 h-5 bg-gradient-to-b from-blue-500 to-blue-600 rounded-full"></div>
Attributen
</h3>
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden shadow-sm">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gradient-to-r from-gray-50 to-gray-100">
<tr>
<th className="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">
Attribuut
</th>
<th className="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">
Waarde
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-100">
{Object.entries(objectData.object as Record<string, any>)
.filter(([key]) => !key.startsWith('_'))
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, value]) => {
const renderedValue = renderAttributeValue(key, value);
if (renderedValue === null) return null;
return (
<tr key={key} className="hover:bg-blue-50/30 transition-colors">
<td className="px-6 py-4 whitespace-nowrap text-sm font-semibold text-gray-700">
{key}
</td>
<td className="px-6 py-4 text-sm">
{renderedValue}
</td>
</tr>
);
})}
{Object.entries(objectData.object as Record<string, any>)
.filter(([key]) => !key.startsWith('_')).length === 0 && (
<tr>
<td colSpan={2} className="px-6 py-12 text-center text-sm text-gray-500">
<svg className="w-12 h-12 mx-auto mb-3 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
</svg>
Geen attributen beschikbaar
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
{/* Metadata */}
{objectData.object && typeof objectData.object === 'object' && '_jiraUpdatedAt' in objectData.object && (
<div>
<h3 className="text-sm font-bold text-gray-700 uppercase tracking-wider mb-4 flex items-center gap-2">
<div className="w-1 h-5 bg-gradient-to-b from-blue-500 to-blue-600 rounded-full"></div>
Metadata
</h3>
<div className="bg-white rounded-xl border border-gray-200 p-6 shadow-sm">
{objectData.object._jiraUpdatedAt && (
<div className="grid grid-cols-2 gap-6">
<div>
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">Laatst bijgewerkt (Jira)</div>
<div className="text-sm font-medium text-gray-900 bg-gray-50 px-3 py-2 rounded-lg border border-gray-200">
{new Date(objectData.object._jiraUpdatedAt).toLocaleString('nl-NL')}
</div>
</div>
{objectData.object._jiraCreatedAt && (
<div>
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">Aangemaakt (Jira)</div>
<div className="text-sm font-medium text-gray-900 bg-gray-50 px-3 py-2 rounded-lg border border-gray-200">
{new Date(objectData.object._jiraCreatedAt).toLocaleString('nl-NL')}
</div>
</div>
)}
</div>
)}
</div>
</div>
)}
</div>
) : (
<div className="text-center py-16 text-gray-500">
<div className="w-12 h-12 border-4 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<p className="font-medium">Laden van object details...</p>
</div>
)}
</div>
{/* Footer */}
<div className="px-8 py-5 border-t border-gray-200 bg-gradient-to-r from-gray-50 to-white flex justify-end">
<button
onClick={onClose}
className="px-6 py-2.5 text-sm font-semibold text-gray-700 bg-white border-2 border-gray-300 rounded-xl hover:bg-gray-50 hover:border-gray-400 transition-all shadow-sm hover:shadow-md"
>
Sluiten
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,261 @@
import { useState, useEffect } from 'react';
import { getObjectTypes, setObjectTypeEnabled, type ObjectTypeConfig, fetchApi } from '../services/api';
export default function ObjectTypeConfigEditor() {
const [objectTypes, setObjectTypes] = useState<ObjectTypeConfig[]>([]);
const [loading, setLoading] = useState(true); // Start with loading = true
const [updating, setUpdating] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const [discovering, setDiscovering] = useState(false);
useEffect(() => {
loadObjectTypes();
}, []);
const loadObjectTypes = async () => {
try {
setLoading(true);
setError(null);
console.log('Loading object types...');
const data = await getObjectTypes();
const types = data.objectTypes || [];
console.log('Object types loaded:', types.length, 'types');
if (types.length === 0) {
console.warn('No object types found in database. Schema discovery may be needed.');
}
setObjectTypes(types);
} catch (err) {
console.error('Failed to load object types:', err);
const errorMessage = err instanceof Error ? err.message : 'Failed to load object types';
setError(errorMessage);
// Also log the full error for debugging
if (err && typeof err === 'object' && 'details' in err) {
console.error('Error details:', (err as any).details);
}
} finally {
setLoading(false);
}
};
const handleToggleEnabled = async (objectTypeName: string, currentEnabled: boolean) => {
try {
setUpdating(objectTypeName);
setError(null);
await setObjectTypeEnabled(objectTypeName, !currentEnabled);
await loadObjectTypes(); // Reload to get updated state
} catch (err) {
console.error('Failed to update object type:', err);
setError(err instanceof Error ? err.message : 'Failed to update object type');
} finally {
setUpdating(null);
}
};
const handleDiscoverSchema = async () => {
try {
setDiscovering(true);
setError(null);
await fetchApi<{ success: boolean; message: string }>('/schema/discover', {
method: 'POST',
});
// Wait a bit for discovery to complete, then reload
setTimeout(() => {
loadObjectTypes();
}, 2000);
} catch (err) {
console.error('Failed to discover schema:', err);
setError(err instanceof Error ? err.message : 'Failed to discover schema');
} finally {
setDiscovering(false);
}
};
const filteredTypes = objectTypes.filter(type =>
type.displayName.toLowerCase().includes(searchTerm.toLowerCase()) ||
type.typeName.toLowerCase().includes(searchTerm.toLowerCase())
);
const enabledCount = objectTypes.filter(t => t.enabled).length;
const disabledCount = objectTypes.length - enabledCount;
return (
<div className="space-y-6">
{/* Header */}
<div className="bg-gradient-to-r from-indigo-50 to-blue-50 rounded-xl p-5 border border-indigo-200">
<div className="flex items-center justify-between mb-2">
<div>
<h3 className="text-xl font-bold text-gray-900 mb-2">Object Type Configuratie</h3>
<p className="text-sm text-gray-600">
Configureer welke object types worden gesynchroniseerd. Alle attributen worden altijd gesynchroniseerd.
</p>
</div>
<button
onClick={loadObjectTypes}
disabled={loading}
className="px-4 py-2 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-all font-semibold text-sm disabled:opacity-50"
>
{loading ? 'Laden...' : 'Ververs'}
</button>
</div>
<div className="flex items-center gap-4 mt-4">
<div className="text-sm">
<span className="font-semibold text-gray-700">Totaal:</span>{' '}
<span className="text-gray-900">{objectTypes.length}</span>
</div>
<div className="text-sm">
<span className="font-semibold text-emerald-700">Ingeschakeld:</span>{' '}
<span className="text-emerald-900">{enabledCount}</span>
</div>
<div className="text-sm">
<span className="font-semibold text-gray-700">Uitgeschakeld:</span>{' '}
<span className="text-gray-900">{disabledCount}</span>
</div>
</div>
</div>
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<div className="flex items-start gap-3">
<svg className="w-5 h-5 text-red-600 mt-0.5 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>
<div>
<p className="text-sm font-semibold text-red-800 mb-1">Fout bij laden</p>
<p className="text-sm text-red-700">{error}</p>
</div>
</div>
</div>
)}
{/* Search */}
<div className="bg-white rounded-xl border border-gray-200 p-4 shadow-sm">
<input
type="text"
placeholder="Zoek object type..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
{/* Object Types List */}
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
<div className="px-6 py-4 bg-gradient-to-r from-gray-50 to-white border-b border-gray-200">
<h3 className="text-lg font-semibold text-gray-900">
Object Types ({filteredTypes.length})
</h3>
</div>
{loading ? (
<div className="text-center py-12">
<div className="w-10 h-10 border-4 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
<p className="text-gray-500 font-medium">Object types laden...</p>
<p className="text-xs text-gray-400 mt-2">Dit kan even duren bij de eerste keer</p>
</div>
) : objectTypes.length === 0 ? (
<div className="p-12 text-center text-gray-500">
<svg className="w-16 h-16 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 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<p className="text-lg font-medium">Geen object types gevonden</p>
<p className="text-sm mt-2">
{searchTerm ? (
'Probeer een andere zoekterm'
) : (
<>
De database bevat nog geen object types. Voer eerst een schema discovery uit om object types te ontdekken.
<br />
<button
onClick={handleDiscoverSchema}
disabled={discovering}
className="mt-4 px-6 py-3 bg-gradient-to-r from-blue-600 to-blue-700 text-white rounded-lg hover:from-blue-700 hover:to-blue-800 transition-all font-semibold text-sm disabled:opacity-50 disabled:cursor-not-allowed shadow-lg hover:shadow-xl"
>
{discovering ? (
<>
<span className="inline-block w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin mr-2"></span>
Schema Discovery bezig...
</>
) : (
'Start Schema Discovery'
)}
</button>
</>
)}
</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gradient-to-r from-gray-50 to-gray-100">
<tr>
<th className="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">
Object Type
</th>
<th className="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">
Schema ID
</th>
<th className="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">
Objecten
</th>
<th className="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-4 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">
Actie
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-100">
{filteredTypes.map((type) => (
<tr key={type.typeName} className="hover:bg-gray-50 transition-colors">
<td className="px-6 py-4">
<div className="text-sm font-semibold text-gray-900">{type.displayName}</div>
<div className="text-xs text-gray-500 mt-1 font-mono">{type.typeName}</div>
{type.description && (
<div className="text-xs text-gray-400 mt-1">{type.description}</div>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="text-sm font-mono text-gray-900">{type.schemaId || '-'}</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="text-sm text-gray-900">{type.objectCount.toLocaleString('nl-NL')}</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{type.enabled ? (
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-emerald-100 text-emerald-700 border border-emerald-200">
Ingeschakeld
</span>
) : (
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-gray-100 text-gray-700 border border-gray-200">
Uitgeschakeld
</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
<button
onClick={() => handleToggleEnabled(type.typeName, type.enabled)}
disabled={updating === type.typeName}
className={`px-4 py-2 rounded-lg transition-all font-semibold text-sm disabled:opacity-50 disabled:cursor-not-allowed ${
type.enabled
? 'bg-red-50 text-red-700 hover:bg-red-100 border border-red-200'
: 'bg-emerald-50 text-emerald-700 hover:bg-emerald-100 border border-emerald-200'
}`}
>
{updating === type.typeName
? '...'
: type.enabled
? 'Uitschakelen'
: 'Inschakelen'}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,77 @@
import React from 'react';
interface PageHeaderProps {
title: string;
description?: string;
icon?: React.ReactNode;
actions?: React.ReactNode;
badge?: React.ReactNode;
progress?: {
current: number;
total: number;
label?: string;
};
}
export default function PageHeader({
title,
description,
icon,
actions,
badge,
progress,
}: PageHeaderProps) {
const progressPercentage = progress
? Math.round((progress.current / progress.total) * 100)
: null;
return (
<div className="bg-gradient-to-r from-blue-600 to-blue-700 rounded-xl shadow-lg p-6 md:p-8 text-white mb-6">
<div className="flex flex-col md:flex-row md:items-start md:justify-between gap-4">
<div className="flex items-start gap-4 flex-1">
{icon && (
<div className="bg-white/20 rounded-lg p-3 backdrop-blur-sm flex-shrink-0">
{icon}
</div>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 flex-wrap">
<h1 className="text-2xl md:text-3xl font-bold">{title}</h1>
{badge && <div className="flex-shrink-0">{badge}</div>}
</div>
{description && (
<p className="text-blue-100 text-base md:text-lg mt-2">
{description}
</p>
)}
</div>
</div>
{actions && (
<div className="flex-shrink-0 flex items-start gap-2">
{actions}
</div>
)}
</div>
{/* Progress indicator */}
{progress && (
<div className="mt-6 pt-6 border-t border-blue-500/30">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-blue-100">
{progress.label || 'Voortgang'}
</span>
<span className="text-sm font-semibold">
{progress.current} van {progress.total} ({progressPercentage}%)
</span>
</div>
<div className="w-full bg-blue-500/30 rounded-full h-2.5">
<div
className="bg-white rounded-full h-2.5 transition-all duration-500 ease-out"
style={{ width: `${progressPercentage}%` }}
/>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,4 +1,5 @@
import { Link } from 'react-router-dom';
import PageHeader from './PageHeader';
export default function ReportsDashboard() {
const reports = [
@@ -220,12 +221,15 @@ export default function ReportsDashboard() {
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>
<PageHeader
title="Rapporten"
description="Overzicht van beschikbare rapporten en analyses voor de CMDB"
icon={
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
}
/>
{/* Reports Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">

View File

@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react';
import { useHasPermission } from '../hooks/usePermissions';
import ProtectedRoute from './ProtectedRoute';
import PageHeader from './PageHeader';
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3001';
@@ -164,18 +165,26 @@ export default function RoleManagement() {
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold text-gray-900">Rollenbeheer</h1>
<p className="text-gray-600 mt-1">Beheer rollen en rechten</p>
</div>
<button
onClick={() => setShowCreateModal(true)}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
+ Nieuwe rol
</button>
</div>
<PageHeader
title="Rollenbeheer"
description="Beheer rollen en rechten"
icon={
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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>
}
actions={
<button
onClick={() => setShowCreateModal(true)}
className="px-4 py-2 bg-white text-blue-600 rounded-lg hover:bg-blue-50 transition-colors font-medium text-sm shadow-sm hover:shadow-md flex items-center gap-2"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Nieuwe rol
</button>
}
/>
{error && (
<div className="p-4 bg-red-50 border border-red-200 rounded-lg">

File diff suppressed because it is too large Load Diff

View File

@@ -324,17 +324,17 @@ export default function SearchDashboard() {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50/30 to-slate-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 lg:py-12">
{/* Header Section */}
<div className="text-center mb-10 lg:mb-12">
<div className="inline-flex items-center justify-center w-16 h-16 lg:w-20 lg:h-20 bg-gradient-to-br from-blue-600 via-blue-500 to-indigo-600 rounded-2xl mb-6 shadow-xl shadow-blue-500/20">
<svg className="w-8 h-8 lg:w-10 lg:h-10 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{/* Header Section - Subtle design without blue bar */}
<div className="text-center mb-8 lg:mb-12">
<div className="inline-flex items-center justify-center w-16 h-16 bg-gradient-to-br from-blue-100 to-indigo-100 rounded-2xl mb-4 shadow-sm">
<svg className="w-8 h-8 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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-3xl lg:text-4xl font-bold text-gray-900 mb-3 tracking-tight">
<h1 className="text-3xl lg:text-4xl font-bold text-gray-900 mb-3">
CMDB Zoeken
</h1>
<p className="text-base lg:text-lg text-gray-600 max-w-4xl mx-auto">
<p className="text-base lg:text-lg text-gray-600 max-w-4xl mx-auto lg:whitespace-nowrap">
Zoek naar applicaties, servers, infrastructuur en andere items in de CMDB van Zuyderland
</p>
</div>
@@ -692,7 +692,7 @@ export default function SearchDashboard() {
</Link>
<Link
to="/reports/team-dashboard"
to="/reports"
className="group relative bg-white rounded-2xl p-6 shadow-sm border border-gray-200 hover:shadow-lg hover:border-green-200 transition-all overflow-hidden"
>
<div className="absolute top-0 right-0 w-32 h-32 bg-gradient-to-br from-green-100/50 to-transparent rounded-bl-full opacity-0 group-hover:opacity-100 transition-opacity"></div>

View File

@@ -2,6 +2,7 @@ import { useEffect, useState, useMemo, useRef, useCallback } from 'react';
import { Link } from 'react-router-dom';
import { getTeamDashboardData, getReferenceData } from '../services/api';
import type { TeamDashboardData, TeamDashboardTeam, TeamDashboardSubteam, ApplicationStatus, ReferenceValue } from '../types';
import PageHeader from './PageHeader';
const ALL_STATUSES: ApplicationStatus[] = [
'In Production',
@@ -708,12 +709,15 @@ export default function TeamDashboard() {
return (
<div>
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900">Team-indeling</h1>
<p className="mt-1 text-sm text-gray-500">
Overzicht van applicaties gegroepeerd per Team en Subteam
</p>
</div>
<PageHeader
title="Team-indeling"
description="Overzicht van applicaties gegroepeerd per Team en Subteam"
icon={
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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>
}
/>
{/* Compact Filter Bar */}
<div className="mb-6 bg-gray-50 rounded-lg shadow-sm border border-gray-200 p-3">

View File

@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import PageHeader from './PageHeader';
interface TechnicalDebtApplication {
id: string;
@@ -182,13 +183,15 @@ export default function TechnicalDebtHeatmap() {
return (
<div>
{/* Header */}
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900">Technical Debt Heatmap</h1>
<p className="mt-1 text-sm text-gray-500">
Visualisatie van applicaties met End of Life, End of Support of Deprecated status gecombineerd met BIA classificatie.
Hoge BIA + EOL = kritiek risico.
</p>
</div>
<PageHeader
title="Technical Debt Heatmap"
description="Visualisatie van applicaties met End of Life, End of Support of Deprecated status gecombineerd met BIA classificatie. Hoge BIA + EOL = kritiek risico."
icon={
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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>
}
/>
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">

View File

@@ -0,0 +1,217 @@
import { useState, useEffect } from 'react';
export type ToastType = 'success' | 'error' | 'warning' | 'info';
export interface Toast {
id: string;
message: string;
type: ToastType;
duration?: number; // Auto-dismiss after this many ms (default: 5000)
}
interface ToastContainerProps {
toasts: Toast[];
onDismiss: (id: string) => void;
}
function ToastContainer({ toasts, onDismiss }: ToastContainerProps) {
if (toasts.length === 0) return null;
return (
<div className="fixed top-4 right-4 z-50 space-y-2 max-w-md w-full">
{toasts.map((toast) => (
<ToastItem key={toast.id} toast={toast} onDismiss={onDismiss} />
))}
</div>
);
}
interface ToastItemProps {
toast: Toast;
onDismiss: (id: string) => void;
}
function ToastItem({ toast, onDismiss }: ToastItemProps) {
const [isVisible, setIsVisible] = useState(true);
useEffect(() => {
// Auto-dismiss after duration
const duration = toast.duration ?? 5000;
if (duration > 0) {
const timer = setTimeout(() => {
setIsVisible(false);
setTimeout(() => onDismiss(toast.id), 300); // Wait for animation
}, duration);
return () => clearTimeout(timer);
}
}, [toast.id, toast.duration, onDismiss]);
const colors = {
success: {
bg: 'bg-emerald-50',
border: 'border-emerald-200',
text: 'text-emerald-800',
icon: 'text-emerald-600',
iconBg: 'bg-emerald-100',
},
error: {
bg: 'bg-red-50',
border: 'border-red-200',
text: 'text-red-800',
icon: 'text-red-600',
iconBg: 'bg-red-100',
},
warning: {
bg: 'bg-amber-50',
border: 'border-amber-200',
text: 'text-amber-800',
icon: 'text-amber-600',
iconBg: 'bg-amber-100',
},
info: {
bg: 'bg-blue-50',
border: 'border-blue-200',
text: 'text-blue-800',
icon: 'text-blue-600',
iconBg: 'bg-blue-100',
},
};
const color = colors[toast.type];
const icons = {
success: (
<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-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
error: (
<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>
),
warning: (
<svg className="w-5 h-5" 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>
),
info: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
};
if (!isVisible) return null;
return (
<div
className={`${color.bg} ${color.border} border rounded-lg shadow-lg p-4 flex items-start gap-3 animate-in slide-in-from-top-5 fade-in duration-300`}
>
<div className={`${color.iconBg} rounded-full p-1 flex-shrink-0 ${color.icon}`}>
{icons[toast.type]}
</div>
<div className="flex-1 min-w-0">
<p className={`text-sm font-medium ${color.text} break-words`}>{toast.message}</p>
</div>
<button
onClick={() => {
setIsVisible(false);
setTimeout(() => onDismiss(toast.id), 300);
}}
className={`${color.icon} hover:opacity-70 transition-opacity flex-shrink-0 ml-2`}
aria-label="Close"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
);
}
// Toast hook/store
class ToastManager {
private listeners: Set<(toasts: Toast[]) => void> = new Set();
private toasts: Toast[] = [];
subscribe(listener: (toasts: Toast[]) => void) {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
}
private notify() {
this.listeners.forEach((listener) => listener([...this.toasts]));
}
show(message: string, type: ToastType = 'info', duration?: number) {
const id = Math.random().toString(36).substring(7);
const toast: Toast = { id, message, type, duration };
this.toasts.push(toast);
this.notify();
// Auto-dismiss if duration is set
if (duration !== undefined && duration > 0) {
setTimeout(() => {
this.dismiss(id);
}, duration);
}
return id;
}
dismiss(id: string) {
this.toasts = this.toasts.filter((t) => t.id !== id);
this.notify();
}
success(message: string, duration?: number) {
return this.show(message, 'success', duration ?? 5000);
}
error(message: string, duration?: number) {
return this.show(message, 'error', duration ?? 7000); // Errors stay longer
}
warning(message: string, duration?: number) {
return this.show(message, 'warning', duration ?? 6000);
}
info(message: string, duration?: number) {
return this.show(message, 'info', duration ?? 5000);
}
getToasts(): Toast[] {
return [...this.toasts];
}
}
export const toastManager = new ToastManager();
// Hook to use toasts
export function useToasts() {
const [toasts, setToasts] = useState<Toast[]>(toastManager.getToasts());
useEffect(() => {
const unsubscribe = toastManager.subscribe(setToasts);
return unsubscribe;
}, []);
return {
toasts,
show: (message: string, type?: ToastType, duration?: number) => toastManager.show(message, type, duration),
success: (message: string, duration?: number) => toastManager.success(message, duration),
error: (message: string, duration?: number) => toastManager.error(message, duration),
warning: (message: string, duration?: number) => toastManager.warning(message, duration),
info: (message: string, duration?: number) => toastManager.info(message, duration),
dismiss: (id: string) => toastManager.dismiss(id),
};
}
// Toast container component
export function ToastContainerComponent() {
const { toasts, dismiss } = useToasts();
return <ToastContainer toasts={toasts} onDismiss={dismiss} />;
}

View File

@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react';
import { useHasPermission } from '../hooks/usePermissions';
import ProtectedRoute from './ProtectedRoute';
import PageHeader from './PageHeader';
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3001';
@@ -318,21 +319,26 @@ export default function UserManagement() {
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-3xl font-bold text-gray-900">Gebruikersbeheer</h1>
<p className="text-gray-600 mt-1">Beheer gebruikers, rollen en rechten</p>
</div>
<button
onClick={() => setShowCreateModal(true)}
className="inline-flex items-center gap-2 px-5 py-2.5 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors shadow-sm hover:shadow-md"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
<PageHeader
title="Gebruikersbeheer"
description="Beheer gebruikers, rollen en rechten"
icon={
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
Nieuwe gebruiker
</button>
</div>
}
actions={
<button
onClick={() => setShowCreateModal(true)}
className="px-4 py-2 bg-white text-blue-600 rounded-lg hover:bg-blue-50 transition-colors font-medium text-sm shadow-sm hover:shadow-md 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 4v16m8-8H4" />
</svg>
Nieuwe gebruiker
</button>
}
/>
{/* Success/Error Messages */}
{success && (

View File

@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import PageHeader from './PageHeader';
interface ReferenceValue {
objectId: string;
@@ -173,12 +174,15 @@ export default function ZiRADomainCoverage() {
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-gray-900">ZiRA Domain Coverage</h1>
<p className="mt-1 text-gray-500">
Analyse van welke ZiRA functies goed ondersteund worden versus gaps in IT coverage
</p>
</div>
<PageHeader
title="ZiRA Domain Coverage"
description="Analyse van welke ZiRA functies goed ondersteund worden versus gaps in IT coverage"
icon={
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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>
}
/>
{/* Overall Statistics */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">

View File

@@ -58,7 +58,7 @@ export class ApiError extends Error {
// Base Fetch
// =============================================================================
async function fetchApi<T>(
export async function fetchApi<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
@@ -134,6 +134,26 @@ export async function getRelatedObjects(
return fetchApi<RelatedObjectsResponse>(`/applications/${applicationId}/related/${objectType}${params}`);
}
/**
* Refresh/sync an application from Jira
* Forces a re-sync of the application from Jira and updates the cache
*/
export async function refreshApplication(id: string): Promise<{
status: string;
applicationId: string;
applicationKey: string;
message: string;
}> {
return fetchApi<{
status: string;
applicationId: string;
applicationKey: string;
message: string;
}>(`/cache/refresh-application/${id}`, {
method: 'POST',
});
}
export interface UpdateApplicationOptions {
/** The _jiraUpdatedAt from when the application was loaded for editing */
originalUpdatedAt?: string;
@@ -672,6 +692,7 @@ export interface SchemaAttributeDefinition {
referenceTypeId?: number;
referenceTypeName?: string;
description?: string;
position?: number;
}
export interface SchemaObjectTypeDefinition {
@@ -680,6 +701,7 @@ export interface SchemaObjectTypeDefinition {
typeName: string;
syncPriority: number;
objectCount: number;
enabled: boolean; // Whether this object type is enabled for syncing
attributes: SchemaAttributeDefinition[];
incomingLinks: Array<{
fromType: string;
@@ -697,6 +719,7 @@ export interface SchemaObjectTypeDefinition {
export interface SchemaResponse {
metadata: {
enabledObjectTypeCount?: number;
generatedAt: string;
objectTypeCount: number;
totalAttributes: number;
@@ -763,3 +786,358 @@ export async function getBIAComparison(): Promise<BIAComparisonResponse> {
export async function getBusinessImportanceComparison(): Promise<BusinessImportanceComparisonResponse> {
return fetchApi<BusinessImportanceComparisonResponse>('/applications/business-importance-comparison');
}
// =============================================================================
// Data Validation
// =============================================================================
export interface DataValidationStats {
cache: {
totalObjects: number;
totalRelations: number;
objectsByType: Record<string, number>;
isWarm: boolean;
dbSizeBytes: number;
lastFullSync: string | null;
lastIncrementalSync: string | null;
};
jira: {
counts: Record<string, number>;
};
comparison: {
typeComparisons: Array<{
typeName: string;
typeDisplayName: string;
schemaId?: string;
schemaName?: string;
cacheCount: number;
jiraCount: number;
difference: number;
syncStatus: 'synced' | 'outdated' | 'missing';
}>;
totalOutdated: number;
totalMissing: number;
totalSynced: number;
};
validation: {
brokenReferences: number;
};
relations: {
total: number;
};
}
export interface DataValidationObjectsResponse {
typeName: string;
typeDisplayName: string;
objects: unknown[];
pagination: {
limit: number;
offset: number;
total: number;
hasMore: boolean;
};
}
export interface DataValidationObjectResponse {
object: unknown;
metadata: {
typeName: string;
typeDisplayName: string;
objectKey: string;
label: string;
};
}
export interface BrokenReference {
object_id: string;
attribute_id: number;
reference_object_id: string;
field_name: string;
object_type_name: string;
object_key: string;
label: string;
}
export interface BrokenReferencesResponse {
brokenReferences: BrokenReference[];
pagination: {
limit: number;
offset: number;
total: number;
hasMore: boolean;
};
}
export async function getDataValidationStats(): Promise<DataValidationStats> {
return fetchApi<DataValidationStats>('/data-validation/stats');
}
export async function getDataValidationObjects(
typeName: string,
limit: number = 10,
offset: number = 0
): Promise<DataValidationObjectsResponse> {
return fetchApi<DataValidationObjectsResponse>(`/data-validation/objects/${typeName}?limit=${limit}&offset=${offset}`);
}
export async function getDataValidationObject(id: string): Promise<DataValidationObjectResponse> {
return fetchApi<DataValidationObjectResponse>(`/data-validation/object/${id}`);
}
export async function getBrokenReferences(
limit: number = 50,
offset: number = 0
): Promise<BrokenReferencesResponse> {
return fetchApi<BrokenReferencesResponse>(`/data-validation/broken-references?limit=${limit}&offset=${offset}`);
}
export interface RepairBrokenReferencesOptions {
mode?: 'delete' | 'fetch' | 'dry-run';
batchSize?: number;
maxRepairs?: number;
}
export interface RepairBrokenReferencesResponse {
status: string;
mode: string;
result: {
total: number;
repaired: number;
deleted: number;
failed: number;
errors: Array<{ reference: BrokenReference; error: string }>;
};
}
export async function repairBrokenReferences(
options: RepairBrokenReferencesOptions = {}
): Promise<RepairBrokenReferencesResponse> {
const params = new URLSearchParams();
if (options.mode) params.append('mode', options.mode);
if (options.batchSize) params.append('batchSize', String(options.batchSize));
if (options.maxRepairs) params.append('maxRepairs', String(options.maxRepairs));
const queryString = params.toString();
return fetchApi<RepairBrokenReferencesResponse>(
`/data-validation/repair-broken-references${queryString ? `?${queryString}` : ''}`,
{ method: 'POST' }
);
}
export interface ValidationStatus {
brokenReferences: number;
objectsWithBrokenRefs: number;
lastValidated: string;
}
export async function getValidationStatus(): Promise<ValidationStatus> {
return fetchApi<ValidationStatus>('/data-validation/validation-status');
}
export interface FullIntegrityCheckResponse {
status: string;
result: {
validation: ValidationStatus;
repair?: RepairBrokenReferencesResponse['result'];
orphanedValues: number;
orphanedRelations: number;
};
}
export async function runFullIntegrityCheck(repair: boolean = false): Promise<FullIntegrityCheckResponse> {
return fetchApi<FullIntegrityCheckResponse>(
`/data-validation/full-integrity-check?repair=${repair}`,
{ method: 'POST' }
);
}
// =============================================================================
// Schema Mappings
// =============================================================================
export interface SchemaMapping {
objectTypeName: string;
schemaId: string;
enabled: boolean;
createdAt: string;
updatedAt: string;
}
export interface SchemaMappingsResponse {
mappings: SchemaMapping[];
}
export async function getSchemaMappings(): Promise<SchemaMappingsResponse> {
return fetchApi<SchemaMappingsResponse>('/data-validation/schema-mappings');
}
export async function setSchemaMapping(
objectTypeName: string,
schemaId: string,
enabled: boolean = true
): Promise<{ status: string; message: string }> {
return fetchApi<{ status: string; message: string }>('/data-validation/schema-mappings', {
method: 'POST',
body: JSON.stringify({ objectTypeName, schemaId, enabled }),
});
}
export async function deleteSchemaMapping(objectTypeName: string): Promise<{ status: string; message: string }> {
return fetchApi<{ status: string; message: string }>(`/data-validation/schema-mappings/${objectTypeName}`, {
method: 'DELETE',
});
}
// =============================================================================
// Object Type Configuration
// =============================================================================
export interface ObjectTypeConfig {
typeName: string;
displayName: string;
description: string | null;
schemaId: string | null;
enabled: boolean;
objectCount: number;
syncPriority: number;
}
export interface ObjectTypesResponse {
objectTypes: ObjectTypeConfig[];
}
export async function getObjectTypes(): Promise<ObjectTypesResponse> {
return fetchApi<ObjectTypesResponse>('/data-validation/object-types');
}
export async function setObjectTypeEnabled(
objectTypeName: string,
enabled: boolean
): Promise<{ status: string; message: string }> {
return fetchApi<{ status: string; message: string }>(
`/data-validation/object-types/${objectTypeName}/enabled`,
{
method: 'PATCH',
body: JSON.stringify({ enabled }),
}
);
}
// =============================================================================
// Schema Configuration
// =============================================================================
export interface SchemaConfigurationStats {
totalSchemas: number;
totalObjectTypes: number;
enabledObjectTypes: number;
disabledObjectTypes: number;
isConfigured: boolean;
}
export interface ConfiguredObjectType {
id: string;
schemaId: string;
schemaName: string;
objectTypeId: number;
objectTypeName: string;
displayName: string;
description: string | null;
objectCount: number;
enabled: boolean;
discoveredAt: string;
updatedAt: string;
}
export interface SchemaWithObjectTypes {
schemaId: string;
schemaName: string;
objectTypes: ConfiguredObjectType[];
}
export interface SchemaConfigurationResponse {
schemas: SchemaWithObjectTypes[];
}
export interface ConfigurationCheckResponse {
isConfigured: boolean;
stats: SchemaConfigurationStats;
}
export interface DiscoverSchemasResponse {
success: boolean;
message: string;
schemasDiscovered: number;
objectTypesDiscovered: number;
}
export async function getSchemaConfigurationStats(): Promise<SchemaConfigurationStats> {
return fetchApi<SchemaConfigurationStats>('/schema-configuration/stats');
}
export async function discoverSchemasAndObjectTypes(): Promise<DiscoverSchemasResponse> {
return fetchApi<DiscoverSchemasResponse>('/schema-configuration/discover', {
method: 'POST',
});
}
export interface SchemaSearchConfig {
schemaId: string;
schemaName: string;
searchEnabled: boolean;
}
export interface SchemasResponse {
schemas: SchemaSearchConfig[];
}
export async function getConfiguredObjectTypes(): Promise<SchemaConfigurationResponse> {
return fetchApi<SchemaConfigurationResponse>('/schema-configuration/object-types');
}
export async function getSchemas(): Promise<SchemasResponse> {
return fetchApi<SchemasResponse>('/schema-configuration/schemas');
}
export async function setSchemaSearchEnabled(
schemaId: string,
searchEnabled: boolean
): Promise<{ status: string; message: string }> {
return fetchApi<{ status: string; message: string }>(
`/schema-configuration/schemas/${schemaId}/search-enabled`,
{
method: 'PATCH',
body: JSON.stringify({ searchEnabled }),
}
);
}
export async function setConfiguredObjectTypeEnabled(
id: string,
enabled: boolean
): Promise<{ status: string; message: string }> {
return fetchApi<{ status: string; message: string }>(
`/schema-configuration/object-types/${id}/enabled`,
{
method: 'PATCH',
body: JSON.stringify({ enabled }),
}
);
}
export async function bulkSetObjectTypesEnabled(
updates: Array<{ id: string; enabled: boolean }>
): Promise<{ status: string; message: string }> {
return fetchApi<{ status: string; message: string }>(
'/schema-configuration/object-types/bulk-enabled',
{
method: 'PATCH',
body: JSON.stringify({ updates }),
}
);
}
export async function checkConfiguration(): Promise<ConfigurationCheckResponse> {
return fetchApi<ConfigurationCheckResponse>('/schema-configuration/check');
}