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:
@@ -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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "zira-frontend",
|
||||
"name": "cmdb-insight-frontend",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
876
frontend/src/components/ArchitectureDebugPage.tsx
Normal file
876
frontend/src/components/ArchitectureDebugPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
789
frontend/src/components/DataValidationDashboard.tsx
Normal file
789
frontend/src/components/DataValidationDashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
342
frontend/src/components/ObjectDetailModal.tsx
Normal file
342
frontend/src/components/ObjectDetailModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
261
frontend/src/components/ObjectTypeConfigEditor.tsx
Normal file
261
frontend/src/components/ObjectTypeConfigEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
77
frontend/src/components/PageHeader.tsx
Normal file
77
frontend/src/components/PageHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
1096
frontend/src/components/SchemaConfigurationSettings.tsx
Normal file
1096
frontend/src/components/SchemaConfigurationSettings.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
217
frontend/src/components/Toast.tsx
Normal file
217
frontend/src/components/Toast.tsx
Normal 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} />;
|
||||
}
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user