import { useEffect, useState, useRef, type ReactNode } from 'react'; import { useParams, Link } from 'react-router-dom'; import { clsx } from 'clsx'; import { getApplicationById, getConfig, getRelatedObjects, refreshApplication, RelatedObject, } from '../services/api'; import { StatusBadge, BusinessImportanceBadge } from './ApplicationList'; import { EffortDisplay } from './EffortDisplay'; import { useEffortCalculation, getEffectiveFte } from '../hooks/useEffortCalculation'; import { useAuthStore } from '../stores/authStore'; import type { ApplicationDetails, ReferenceValue } from '../types'; // Related objects configuration interface RelatedObjectConfig { objectType: string; title: string; icon: React.ReactNode; attributes: string[]; columns: { key: string; label: string; isName?: boolean }[]; colorScheme: 'blue' | 'green' | 'orange' | 'purple' | 'cyan'; } const RELATED_OBJECTS_CONFIG: RelatedObjectConfig[] = [ { objectType: 'Server', title: 'Servers', icon: ( ), attributes: ['Name', 'Status', 'State'], columns: [ { key: 'Name', label: 'Naam', isName: true }, { key: 'Status', label: 'Status' }, { key: 'State', label: 'State' }, ], colorScheme: 'blue', }, { objectType: 'AzureSubscription', title: 'Azure Subscriptions', icon: ( ), attributes: ['Name', 'Status'], columns: [ { key: 'Name', label: 'Naam', isName: true }, { key: 'Status', label: 'Status' }, ], colorScheme: 'cyan', }, { objectType: 'Certificate', title: 'Certificaten', icon: ( ), attributes: ['Name', 'Status', 'Expiry Date', 'Autorenew', 'Requester', 'Certificate Owner', 'IT Operations Team', 'Application Management'], columns: [ { key: 'Name', label: 'Naam', isName: true }, { key: 'Status', label: 'Status' }, { key: 'Expiry Date', label: 'Vervaldatum' }, { key: 'Autorenew', label: 'Auto-renew' }, { key: 'Certificate Owner', label: 'Eigenaar' }, ], colorScheme: 'orange', }, { objectType: 'Connection', title: 'Connecties', icon: ( ), attributes: ['Name', 'Source', 'Target', 'Type', 'Protocol'], columns: [ { key: 'Name', label: 'Naam', isName: true }, { key: 'Source', label: 'Bron' }, { key: 'Target', label: 'Doel' }, { key: 'Type', label: 'Type' }, { key: 'Protocol', label: 'Protocol' }, ], colorScheme: 'purple', }, ]; const COLOR_SCHEMES = { blue: { header: 'bg-blue-50', icon: 'text-blue-600', badge: 'bg-blue-100 text-blue-700', border: 'border-blue-200', }, green: { header: 'bg-green-50', icon: 'text-green-600', badge: 'bg-green-100 text-green-700', border: 'border-green-200', }, orange: { header: 'bg-orange-50', icon: 'text-orange-600', badge: 'bg-orange-100 text-orange-700', border: 'border-orange-200', }, purple: { header: 'bg-purple-50', icon: 'text-purple-600', badge: 'bg-purple-100 text-purple-700', border: 'border-purple-200', }, cyan: { header: 'bg-cyan-50', icon: 'text-cyan-600', badge: 'bg-cyan-100 text-cyan-700', border: 'border-cyan-200', }, }; export default function ApplicationInfo() { const { id } = useParams<{ id: string }>(); const [application, setApplication] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [jiraHost, setJiraHost] = useState(''); const [refreshing, setRefreshing] = useState(false); const [refreshMessage, setRefreshMessage] = useState(null); // Use centralized effort calculation hook const { calculatedFte, breakdown: effortBreakdown } = useEffortCalculation({ application, }); // Related objects state const [relatedObjects, setRelatedObjects] = useState>(new Map()); const [expandedSections, setExpandedSections] = useState>(new Set()); // Default collapsed const [supplierExpanded, setSupplierExpanded] = useState(true); // Leverancier(s) block expanded by default const [contactsExpanded, setContactsExpanded] = useState(true); // Contactpersonen block expanded by default const [basisInfoExpanded, setBasisInfoExpanded] = useState(true); // Basis informatie block expanded by default const [governanceExpanded, setGovernanceExpanded] = useState(true); // Governance & Management block expanded by default const [classificationExpanded, setClassificationExpanded] = useState(false); // Classificatie block collapsed by default useEffect(() => { async function fetchData() { if (!id) return; setLoading(true); setError(null); try { const [app, config] = await Promise.all([ getApplicationById(id), getConfig(), ]); setApplication(app); setJiraHost(config.jiraHost); // Note: Effort calculation is handled automatically by useEffortCalculation hook } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load application'); } finally { setLoading(false); } } fetchData(); }, [id]); // Set page title useEffect(() => { const appName = useAuthStore.getState().config?.appName || 'CMDB Insight'; if (application) { document.title = `${application.name} | ${appName}`; } return () => { document.title = appName; }; }, [application]); // Fetch related objects when application is loaded useEffect(() => { if (!id || !application) return; // Initialize loading state for all object types const initialState = new Map(); RELATED_OBJECTS_CONFIG.forEach(config => { initialState.set(config.objectType, { objects: [], loading: true, error: null }); }); setRelatedObjects(initialState); // Fetch each object type in parallel RELATED_OBJECTS_CONFIG.forEach(async (config) => { try { const result = await getRelatedObjects(id, config.objectType, config.attributes); setRelatedObjects(prev => { const newMap = new Map(prev); newMap.set(config.objectType, { objects: result?.objects || [], loading: false, error: null }); return newMap; }); } catch (err) { setRelatedObjects(prev => { const newMap = new Map(prev); newMap.set(config.objectType, { objects: [], loading: false, error: err instanceof Error ? err.message : 'Failed to load' }); return newMap; }); } }); }, [id, application]); const toggleSection = (objectType: string) => { setExpandedSections(prev => { const newSet = new Set(prev); if (newSet.has(objectType)) { newSet.delete(objectType); } else { newSet.add(objectType); } return newSet; }); }; const handleBasisInfoToggle = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); e.nativeEvent.stopImmediatePropagation(); setBasisInfoExpanded(prev => !prev); }; const handleGovernanceToggle = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); e.nativeEvent.stopImmediatePropagation(); setGovernanceExpanded(prev => !prev); }; const handleRefresh = async () => { if (!id || refreshing) return; setRefreshing(true); setRefreshMessage(null); try { 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 (

Applicatie laden...

Gegevens worden opgehaald uit de CMDB

); } if (error || !application) { return (

Fout bij laden

{error || 'Application not found'}

Terug naar overzicht
); } return (
{/* Back navigation */}
Terug naar overzicht
{/* Header with application name and quick actions */}
{/* Header gradient */}

{application.name}

{application.hostingType && ( {application.hostingType.name} {application.hostingType.objectId && jiraHost && ( e.stopPropagation()} > )} )}
{/* Quick action buttons */}
{application.technischeArchitectuur && application.technischeArchitectuur.trim() !== '' && ( )} {application.confluenceSpace && application.confluenceSpace.trim() !== '' && ( )} {jiraHost && application.key && ( )}
{/* Status badge on the right */}
{/* Refresh message */} {refreshMessage && (
{refreshMessage.includes('succesvol') || refreshMessage.includes('gesynchroniseerd') ? ( ) : ( )} {refreshMessage}
)} {/* Reference warning - only show if reference is truly empty */} {(() => { const refValue = application.reference; // Helper function to check if a string is truly empty (handles all edge cases) const isStringEmpty = (str: unknown): boolean => { if (str === null || str === undefined) { return true; } if (typeof str !== 'string') { // If it's not a string, we can't determine if it's empty, so assume it's not empty return false; } // Check for empty string if (str === '') { return true; } // Trim and check - this handles spaces, tabs, newlines, etc. const trimmed = str.trim(); if (trimmed === '') { return true; } // Check for whitespace-only strings using regex (handles Unicode whitespace too) if (/^\s*$/.test(str)) { return true; } // Check for strings that only contain zero-width characters if (trimmed.replace(/[\u200B-\u200D\uFEFF]/g, '') === '') { return true; } // Has meaningful content return false; }; const isEmpty = isStringEmpty(refValue); return isEmpty; })() && (

Deze Application Component is (nog) niet toegevoegd in Enterprise Architect

)} {/* Description */}
{application.description && (

{application.description}

)} {/* Application Functions pills/tags below description */} {application.applicationFunctions && application.applicationFunctions.length > 0 && (
{application.applicationFunctions.map((func, index) => (
{index === 0 && ( )} {func.name} {func.objectId && jiraHost && ( e.stopPropagation()} > )}
))}
)}
{/* Basis informatie and Governance & Management side by side */}
{/* Basis informatie */}
{basisInfoExpanded && (
{application.businessImpactAnalyse?.name ? ( <> {application.businessImpactAnalyse.name} {/* Info icon with description tooltip - shown when description exists */} {(application.businessImpactAnalyse?.description || application.businessImpactAnalyse?.indicators) && ( )} {/* External link to Jira Assets - shown when objectId exists */} {application.businessImpactAnalyse.objectId && jiraHost && ( e.stopPropagation()} > )} ) : ( <> Niet ingevuld {/* Info icon with description tooltip - shown when description exists */} {(application.businessImpactAnalyse?.description || application.businessImpactAnalyse?.indicators) && ( )} )}
{application.businessImpactAnalyse?.indicators && (

{application.businessImpactAnalyse.indicators}

)}
{application.dataCompletenessPercentage !== undefined && (
= 80 ? 'bg-gradient-to-r from-green-500 to-green-600' : application.dataCompletenessPercentage >= 60 ? 'bg-gradient-to-r from-yellow-500 to-yellow-600' : 'bg-gradient-to-r from-red-500 to-red-600' }`} style={{ width: `${application.dataCompletenessPercentage}%` }} />
= 80 ? 'text-green-700' : application.dataCompletenessPercentage >= 60 ? 'text-yellow-700' : 'text-red-700' }`}> {application.dataCompletenessPercentage.toFixed(1)}%
)}
)}
{/* Governance & Management */}
{governanceExpanded && (
{ const teamName = application.applicationTeam?.name; const subteamName = application.applicationSubteam?.name; if (teamName) { return subteamName ? `${teamName} (${subteamName})` : teamName; } return subteamName || undefined; })()} referenceValue={application.applicationTeam || application.applicationSubteam} jiraHost={jiraHost} />
)}
{/* Contacts & Leverancier(s) blocks side by side */}
{/* Contacts */}
{contactsExpanded && (
{ const mainValue = application.technicalApplicationManagement; const primary = application.technicalApplicationManagementPrimary?.trim(); const secondary = application.technicalApplicationManagementSecondary?.trim(); const parts = []; if (primary) parts.push(primary); if (secondary) parts.push(secondary); if (mainValue) { return parts.length > 0 ? `${mainValue} (${parts.join(', ')})` : mainValue; } return parts.length > 0 ? `(${parts.join(', ')})` : undefined; })()} />
)}
{/* Leverancier(s) block */}
{supplierExpanded && (
)}
{/* Classification section */}
{classificationExpanded && (
{/* FTE - Benodigde inspanning applicatiemanagement */}

Automatisch berekend op basis van Regiemodel, Application Type, Business Impact Analyse en Hosting (v25)

)}
{/* Related Objects Sections */}

Gerelateerde objecten

{RELATED_OBJECTS_CONFIG.map((config) => { const data = relatedObjects.get(config.objectType); const isExpanded = expandedSections.has(config.objectType); const colors = COLOR_SCHEMES[config.colorScheme]; const objects = data?.objects || []; const count = objects.length; const isLoading = data?.loading ?? true; return (
{/* Header - clickable to expand/collapse */} {/* Content */} {isExpanded && (
{isLoading ? (

Laden...

) : data?.error ? (

{data.error}

) : count === 0 ? (

Geen {config.title.toLowerCase()} gevonden

Er zijn geen gerelateerde {config.title.toLowerCase()} gekoppeld aan deze applicatie

) : (
{config.columns.map((col) => ( ))} {objects.map((obj) => ( {config.columns.map((col) => ( ))} ))}
{col.label}
{col.isName && jiraHost ? ( {obj.attributes[col.key] || obj.name || '-'} ) : ( {obj.attributes[col.key] || -} )}
)}
)}
); })}
{/* Call to action */}

Classificatie aanpassen?

Bewerk applicatiefuncties, classificatie en regiemodel met AI-ondersteuning.

Bewerken
); } // Helper component for tooltip with fixed positioning to escape overflow-hidden containers // Uses the same beautiful styling as the Team-indeling popup function Tooltip({ children, text }: { children: ReactNode; text: string }) { const [isVisible, setIsVisible] = useState(false); const [position, setPosition] = useState({ top: 0, left: 0 }); const wrapperRef = useRef(null); const tooltipRef = useRef(null); const updatePosition = () => { if (wrapperRef.current && tooltipRef.current) { const rect = wrapperRef.current.getBoundingClientRect(); const tooltipRect = tooltipRef.current.getBoundingClientRect(); // Calculate centered horizontal position (50% from left edge of trigger) let left = rect.left + rect.width / 2; // Keep tooltip within viewport bounds const padding = 16; const tooltipWidth = 320; // w-80 = 320px (same as Team-indeling) // Adjust horizontal position if tooltip would overflow if (left - tooltipWidth / 2 < padding) { left = padding + tooltipWidth / 2; } else if (left + tooltipWidth / 2 > window.innerWidth - padding) { left = window.innerWidth - padding - tooltipWidth / 2; } // Position above with spacing (same as Team-indeling: mt-3 = 12px) // Note: Team-indeling uses 'top-full mt-3' which means: position at bottom of trigger + 12px // But we want it above, so: position at top of trigger - 12px - tooltip height let top = rect.top - tooltipRect.height - 12; if (top < padding) { // If tooltip would overflow top, position below instead top = rect.bottom + 12; } setPosition({ top, left, }); } }; const handleMouseEnter = () => { setIsVisible(true); // Small delay to ensure tooltip is rendered before calculating position setTimeout(() => updatePosition(), 0); }; const handleMouseLeave = () => { setIsVisible(false); }; // Update position on scroll or resize while tooltip is visible useEffect(() => { if (isVisible) { const handleScroll = () => updatePosition(); const handleResize = () => updatePosition(); window.addEventListener('scroll', handleScroll, true); window.addEventListener('resize', handleResize); return () => { window.removeEventListener('scroll', handleScroll, true); window.removeEventListener('resize', handleResize); }; } }, [isVisible, text]); return ( <>
{children}
{isVisible && (
e.stopPropagation()} > {/* Arrow pointer (exact same style as Team-indeling) */}
{/* Description text (exact same styling as Team-indeling remarks) */}
{text}
)} ); } // Helper component for info icon with tooltip function InfoIcon({ description }: { description?: string | null }) { if (!description || description.trim() === '') return null; return ( ); } // Helper component for external link icon function ExternalLinkIcon({ className = "w-4 h-4" }: { className?: string }) { return ( ); } // Helper component for displaying info rows with optional info icon and Jira Assets link function InfoRow({ label, value, referenceValue, infoDescription, jiraHost }: { label: string; value?: string | null; referenceValue?: ReferenceValue | null; infoDescription?: string | null; jiraHost?: string }) { // If referenceValue is provided, use it; otherwise use value string const displayValue = referenceValue?.name || value; const objectId = referenceValue?.objectId; // Get description: use infoDescription if provided, otherwise fall back to referenceValue?.description const description = infoDescription || referenceValue?.description || null; // Debug: log referenceValue info for fields that should have descriptions if (referenceValue && import.meta.env.DEV) { } return (

{displayValue || Niet ingevuld}

{/* Info icon with description tooltip - shown when description exists */} {description && ( )} {/* External link to Jira Assets - shown when objectId exists */} {objectId && jiraHost && ( )}
); }