Files
cmdb-insight/frontend/src/components/ApplicationInfo.tsx
Bert Hausmans cdee0e8819 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
2026-01-21 03:24:56 +01:00

1301 lines
64 KiB
TypeScript

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: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
</svg>
),
attributes: ['Name', 'Status', 'State'],
columns: [
{ key: 'Name', label: 'Naam', isName: true },
{ key: 'Status', label: 'Status' },
{ key: 'State', label: 'State' },
],
colorScheme: 'blue',
},
{
objectType: 'AzureSubscription',
title: 'Azure Subscriptions',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z" />
</svg>
),
attributes: ['Name', 'Status'],
columns: [
{ key: 'Name', label: 'Naam', isName: true },
{ key: 'Status', label: 'Status' },
],
colorScheme: 'cyan',
},
{
objectType: 'Certificate',
title: 'Certificaten',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
),
attributes: ['Name', 'Status', 'Expiry Date', 'Autorenew', 'Requester', 'Certificate Owner', 'IT Operations Team', 'Application Management'],
columns: [
{ key: 'Name', label: 'Naam', isName: true },
{ key: 'Status', label: 'Status' },
{ key: 'Expiry Date', label: 'Vervaldatum' },
{ key: 'Autorenew', label: 'Auto-renew' },
{ key: 'Certificate Owner', label: 'Eigenaar' },
],
colorScheme: 'orange',
},
{
objectType: 'Connection',
title: 'Connecties',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
),
attributes: ['Name', 'Source', 'Target', 'Type', 'Protocol'],
columns: [
{ key: 'Name', label: 'Naam', isName: true },
{ key: 'Source', label: 'Bron' },
{ key: 'Target', label: 'Doel' },
{ key: 'Type', label: 'Type' },
{ key: 'Protocol', label: 'Protocol' },
],
colorScheme: 'purple',
},
];
const COLOR_SCHEMES = {
blue: {
header: 'bg-blue-50',
icon: 'text-blue-600',
badge: 'bg-blue-100 text-blue-700',
border: 'border-blue-200',
},
green: {
header: 'bg-green-50',
icon: 'text-green-600',
badge: 'bg-green-100 text-green-700',
border: 'border-green-200',
},
orange: {
header: 'bg-orange-50',
icon: 'text-orange-600',
badge: 'bg-orange-100 text-orange-700',
border: 'border-orange-200',
},
purple: {
header: 'bg-purple-50',
icon: 'text-purple-600',
badge: 'bg-purple-100 text-purple-700',
border: 'border-purple-200',
},
cyan: {
header: 'bg-cyan-50',
icon: 'text-cyan-600',
badge: 'bg-cyan-100 text-cyan-700',
border: 'border-cyan-200',
},
};
export default function ApplicationInfo() {
const { id } = useParams<{ id: string }>();
const [application, setApplication] = useState<ApplicationDetails | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [jiraHost, setJiraHost] = useState<string>('');
const [refreshing, setRefreshing] = useState(false);
const [refreshMessage, setRefreshMessage] = useState<string | null>(null);
// Use centralized effort calculation hook
const { calculatedFte, breakdown: effortBreakdown } = useEffortCalculation({
application,
});
// Related objects state
const [relatedObjects, setRelatedObjects] = useState<Map<string, { objects: RelatedObject[]; loading: boolean; error: string | null }>>(new Map());
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set()); // 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<string, { objects: RelatedObject[]; loading: boolean; error: string | null }>();
RELATED_OBJECTS_CONFIG.forEach(config => {
initialState.set(config.objectType, { objects: [], loading: true, error: null });
});
setRelatedObjects(initialState);
// Fetch each object type in parallel
RELATED_OBJECTS_CONFIG.forEach(async (config) => {
try {
const result = await getRelatedObjects(id, config.objectType, config.attributes);
setRelatedObjects(prev => {
const newMap = new Map(prev);
newMap.set(config.objectType, { objects: result?.objects || [], loading: false, error: null });
return newMap;
});
} catch (err) {
setRelatedObjects(prev => {
const newMap = new Map(prev);
newMap.set(config.objectType, {
objects: [],
loading: false,
error: err instanceof Error ? err.message : 'Failed to load'
});
return newMap;
});
}
});
}, [id, application]);
const toggleSection = (objectType: string) => {
setExpandedSections(prev => {
const newSet = new Set(prev);
if (newSet.has(objectType)) {
newSet.delete(objectType);
} else {
newSet.add(objectType);
}
return newSet;
});
};
const handleBasisInfoToggle = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
e.nativeEvent.stopImmediatePropagation();
setBasisInfoExpanded(prev => !prev);
};
const handleGovernanceToggle = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
e.nativeEvent.stopImmediatePropagation();
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">
<div className="text-center">
<div className="inline-flex items-center justify-center w-20 h-20 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-2xl mb-6 shadow-xl shadow-blue-500/20">
<svg className="animate-spin h-10 w-10 text-white" 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>
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">Applicatie laden...</h3>
<p className="text-sm text-gray-600">Gegevens worden opgehaald uit de CMDB</p>
</div>
</div>
);
}
if (error || !application) {
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 px-4">
<div className="max-w-2xl w-full">
<div className="bg-red-50 border-l-4 border-red-500 rounded-xl p-6 shadow-lg">
<div className="flex items-start gap-4">
<div className="flex-shrink-0">
<div className="w-12 h-12 bg-red-100 rounded-full flex items-center justify-center">
<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 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-red-900 mb-2">Fout bij laden</h3>
<p className="text-sm text-red-700 leading-relaxed">{error || 'Application not found'}</p>
<Link
to="/application/overview"
className="inline-flex items-center gap-2 mt-4 text-sm font-medium text-red-700 hover:text-red-900"
>
<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 naar overzicht
</Link>
</div>
</div>
</div>
</div>
</div>
);
}
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">
<div className="space-y-6">
{/* Back navigation */}
<div className="flex items-center">
<Link
to="/application/overview"
className="inline-flex items-center gap-2 px-4 py-2 text-gray-600 hover:text-gray-900 font-medium transition-all rounded-lg hover:bg-white/50 group"
>
<svg
className="w-5 h-5 transition-transform group-hover:-translate-x-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 19l-7-7 7-7"
/>
</svg>
Terug naar overzicht
</Link>
</div>
{/* Header with application name and quick actions */}
<div className="bg-white rounded-2xl shadow-lg border border-gray-200 overflow-hidden">
{/* Header gradient */}
<div className="bg-gradient-to-r from-blue-600 via-blue-500 to-indigo-600 px-6 lg:px-8 py-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-2 flex-wrap">
<div className="flex items-center gap-3 flex-wrap">
<h1 className="text-2xl lg:text-3xl font-bold text-white break-words">{application.name}</h1>
{application.hostingType && (
<span className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium bg-blue-100 text-blue-700 border border-blue-200">
{application.hostingType.name}
{application.hostingType.objectId && jiraHost && (
<Tooltip text="Openen in Jira Assets">
<a
href={`${jiraHost}/secure/insight/assets/ICMT-${application.hostingType.objectId}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center text-blue-600 hover:text-blue-800 transition-colors"
onClick={(e) => e.stopPropagation()}
>
<ExternalLinkIcon className="w-3.5 h-3.5" />
</a>
</Tooltip>
)}
</span>
)}
</div>
{/* Quick action buttons */}
<div className="flex flex-wrap gap-2">
{application.technischeArchitectuur && application.technischeArchitectuur.trim() !== '' && (
<Tooltip text="Open het TA document">
<a
href={`${application.technischeArchitectuur}${application.technischeArchitectuur.includes('?') ? '&' : '?'}csf=1&web=1`}
target="_blank"
rel="noopener noreferrer"
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"
>
<svg className="w-5 h-5" 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>
</a>
</Tooltip>
)}
{application.confluenceSpace && application.confluenceSpace.trim() !== '' && (
<Tooltip text="Openen in Confluence">
<a
href={application.confluenceSpace}
target="_blank"
rel="noopener noreferrer"
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"
>
<svg className="w-5 h-5" viewBox="0 0 75 75" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M54.3,44.6c-11.6-5.6-15-6.4-19.8-6.4s-10.6,2.4-15,9.1l-.7,1.1c-.6.9-.7,1.2-.7,1.6s.2.7.9,1.2l7.3,4.6c.4.3.7.4,1,.4s.7-.2,1-.8l1.2-1.8c1.8-2.8,3.4-3.7,5.5-3.7s4,.5,6.6,1.8l7.7,3.6c.8.4,1.6.2,2-.7l3.6-8c.4-.9.1-1.5-.8-1.9ZM20.5,30.5c11.6,5.6,15,6.4,19.8,6.4s10.6-2.4,15-9.1l.7-1.1c.6-.9.7-1.2.7-1.6s-.2-.7-.9-1.2l-7.3-4.6c-.4-.3-.7-.4-1-.4s-.7.2-1,.8l-1.2,1.8c-1.8,2.8-3.4,3.7-5.5,3.7s-4-.5-6.6-1.8l-7.7-3.6c-.8-.4-1.6-.2-2,.7l-3.6,8c-.4.9-.1,1.5.8,1.9Z" fill="currentColor"/>
</svg>
</a>
</Tooltip>
)}
{jiraHost && application.key && (
<Tooltip text="Openen in Jira Assets">
<a
href={`${jiraHost}/secure/insight/assets/${application.key}`}
target="_blank"
rel="noopener noreferrer"
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"
>
<ExternalLinkIcon className="w-5 h-5" />
</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>
{/* Status badge on the right */}
<div className="flex-shrink-0">
<StatusBadge status={application.status} variant="header" />
</div>
</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;
// 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;
})() && (
<div className="px-6 lg:px-8 py-4 border-t border-yellow-200 bg-yellow-50">
<div className="flex items-start gap-3">
<svg className="w-5 h-5 text-yellow-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 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 className="flex-1">
<p className="text-sm font-medium text-yellow-800">Deze Application Component is (nog) niet toegevoegd in Enterprise Architect</p>
</div>
</div>
</div>
)}
{/* Description */}
<div className="px-6 lg:px-8 py-6 border-t border-gray-200 bg-gradient-to-br from-gray-50 to-white">
{application.description && (
<div className="flex items-start gap-3 mb-4">
<svg className="w-5 h-5 text-gray-400 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h7" />
</svg>
<p className="text-gray-700 leading-relaxed">{application.description}</p>
</div>
)}
{/* Application Functions pills/tags below description */}
{application.applicationFunctions && application.applicationFunctions.length > 0 && (
<div className="flex flex-wrap gap-2 mt-4">
{application.applicationFunctions.map((func, index) => (
<div
key={func.objectId || index}
className={clsx(
'inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold shadow-sm transition-all',
index === 0
? 'bg-gradient-to-r from-blue-500 to-blue-600 text-white shadow-lg shadow-blue-500/30 hover:shadow-xl hover:shadow-blue-500/40'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 border border-gray-200'
)}
>
{index === 0 && (
<svg className="w-3 h-3" 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>{func.name}</span>
{func.objectId && jiraHost && (
<Tooltip text="Openen in Jira Assets">
<a
href={`${jiraHost}/secure/insight/assets/ICMT-${func.objectId}`}
target="_blank"
rel="noopener noreferrer"
className={clsx(
'inline-flex items-center justify-center transition-colors',
index === 0
? 'text-white/80 hover:text-white'
: 'text-gray-500 hover:text-blue-600'
)}
onClick={(e) => e.stopPropagation()}
>
<ExternalLinkIcon className="w-3 h-3" />
</a>
</Tooltip>
)}
</div>
))}
</div>
)}
</div>
</div>
{/* Basis informatie and Governance & Management side by side */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
{/* Basis informatie */}
<div key="basis-info" className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden transition-all hover:shadow-md">
<button
type="button"
onClick={handleBasisInfoToggle}
className="w-full px-6 py-5 flex items-center justify-between bg-gradient-to-r from-gray-50 to-gray-100/50 hover:from-gray-100 hover:to-gray-200/50 transition-all group cursor-pointer"
>
<div className="flex items-center gap-3">
<svg className="w-5 h-5 text-gray-600 group-hover:text-gray-900 transition-colors" 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>
<h3 className="text-lg font-semibold text-gray-900 group-hover:text-gray-950 transition-colors">Basis informatie</h3>
</div>
<svg
className={`w-5 h-5 text-gray-500 group-hover:text-gray-700 transition-all duration-200 ${basisInfoExpanded ? 'transform rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{basisInfoExpanded && (
<div className="border-t border-gray-200 animate-in slide-in-from-top-2 duration-200">
<div className="p-6 space-y-5">
<InfoRow label="Search Reference" value={application.searchReference} />
<InfoRow label="Organisatie" value={application.organisation} />
<div>
<label className="block text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">Business Importance</label>
<div className="mt-1">
<BusinessImportanceBadge importance={application.businessImportance} />
</div>
</div>
<div>
<label className="block text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">Business Impact Analyse</label>
<div className="flex items-center gap-2">
{application.businessImpactAnalyse?.name ? (
<>
<span className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium bg-blue-100 text-blue-700 border border-blue-200">
{application.businessImpactAnalyse.name}
</span>
{/* Info icon with description tooltip - shown when description exists */}
{(application.businessImpactAnalyse?.description || application.businessImpactAnalyse?.indicators) && (
<span className="inline-flex items-center">
<InfoIcon description={application.businessImpactAnalyse.description || application.businessImpactAnalyse.indicators || undefined} />
</span>
)}
{/* External link to Jira Assets - shown when objectId exists */}
{application.businessImpactAnalyse.objectId && jiraHost && (
<Tooltip text="Openen in Jira Assets">
<a
href={`${jiraHost}/secure/insight/assets/ICMT-${application.businessImpactAnalyse.objectId}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center text-gray-400 hover:text-blue-600 transition-colors"
onClick={(e) => e.stopPropagation()}
>
<ExternalLinkIcon className="w-4 h-4" />
</a>
</Tooltip>
)}
</>
) : (
<>
<span className="text-gray-400 italic">Niet ingevuld</span>
{/* Info icon with description tooltip - shown when description exists */}
{(application.businessImpactAnalyse?.description || application.businessImpactAnalyse?.indicators) && (
<span className="inline-flex items-center">
<InfoIcon description={application.businessImpactAnalyse.description || application.businessImpactAnalyse.indicators || undefined} />
</span>
)}
</>
)}
</div>
{application.businessImpactAnalyse?.indicators && (
<p className="text-sm text-gray-600 mt-2 leading-relaxed">{application.businessImpactAnalyse.indicators}</p>
)}
</div>
{application.dataCompletenessPercentage !== undefined && (
<div>
<label className="block text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">Data Completeness</label>
<div className="flex items-center gap-4">
<div className="flex-1 min-w-[120px]">
<div className="w-full bg-gray-200 rounded-full h-3 overflow-hidden shadow-inner">
<div
className={`h-3 rounded-full transition-all shadow-sm ${
application.dataCompletenessPercentage >= 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}%` }}
/>
</div>
</div>
<span className={`text-base font-bold min-w-[60px] ${
application.dataCompletenessPercentage >= 80
? 'text-green-700'
: application.dataCompletenessPercentage >= 60
? 'text-yellow-700'
: 'text-red-700'
}`}>
{application.dataCompletenessPercentage.toFixed(1)}%
</span>
</div>
</div>
)}
</div>
</div>
)}
</div>
{/* Governance & Management */}
<div key="governance" className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden transition-all hover:shadow-md">
<button
type="button"
onClick={handleGovernanceToggle}
className="w-full px-6 py-5 flex items-center justify-between bg-gradient-to-r from-purple-50 to-pink-50/50 hover:from-purple-100 hover:to-pink-100/50 transition-all group cursor-pointer"
>
<div className="flex items-center gap-3">
<svg className="w-5 h-5 text-purple-600 group-hover:text-purple-700 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
<h3 className="text-lg font-semibold text-gray-900 group-hover:text-gray-950 transition-colors">Governance & Management</h3>
</div>
<svg
className={`w-5 h-5 text-gray-500 group-hover:text-gray-700 transition-all duration-200 ${governanceExpanded ? 'transform rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{governanceExpanded && (
<div className="border-t border-gray-200 animate-in slide-in-from-top-2 duration-200">
<div className="p-6 space-y-5">
<InfoRow
label="ICT Governance Model"
referenceValue={application.governanceModel}
infoDescription={application.governanceModel?.remarks || application.governanceModel?.description || undefined}
jiraHost={jiraHost}
/>
<InfoRow
label="Application Management - Application Type"
referenceValue={application.applicationType}
infoDescription={application.applicationType?.description || undefined}
jiraHost={jiraHost}
/>
<InfoRow
label="Application Management - Hosting"
referenceValue={application.applicationManagementHosting}
infoDescription={application.applicationManagementHosting?.description || undefined}
jiraHost={jiraHost}
/>
<InfoRow
label="Application Management - TAM"
referenceValue={application.applicationManagementTAM}
infoDescription={application.applicationManagementTAM?.description || undefined}
jiraHost={jiraHost}
/>
<InfoRow
label="Application Management - Team"
value={(() => {
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}
/>
</div>
</div>
)}
</div>
</div>
{/* Contacts & Leverancier(s) blocks side by side */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
{/* Contacts */}
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden">
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setContactsExpanded(!contactsExpanded);
}}
className="w-full px-6 py-5 flex items-center justify-between bg-gradient-to-r from-green-50 to-emerald-50/50 hover:opacity-90 transition-all group"
>
<div className="flex items-center gap-3">
<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="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>
<h3 className="text-lg font-semibold text-gray-900">Contactpersonen</h3>
</div>
<svg
className={`w-5 h-5 text-gray-500 transition-transform ${contactsExpanded && 'transform rotate-180'}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{contactsExpanded && (
<div className="border-t border-gray-100">
<div className="p-6 space-y-5">
<InfoRow label="Business Owner" value={application.businessOwner} />
<InfoRow label="System Owner" value={application.systemOwner} />
<InfoRow label="Functional Application Management" value={application.functionalApplicationManagement} />
<InfoRow
label="Technical Application Management"
value={(() => {
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;
})()}
/>
</div>
</div>
)}
</div>
{/* Leverancier(s) block */}
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden">
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setSupplierExpanded(!supplierExpanded);
}}
className="w-full px-6 py-5 flex items-center justify-between bg-gradient-to-r from-orange-50 to-amber-50/50 hover:opacity-90 transition-all group"
>
<div className="flex items-center gap-3">
<svg className="w-5 h-5 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
<h3 className="text-lg font-semibold text-gray-900">Leverancier(s)</h3>
</div>
<svg
className={`w-5 h-5 text-gray-500 transition-transform ${supplierExpanded && 'transform rotate-180'}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{supplierExpanded && (
<div className="border-t border-gray-100">
<div className="p-6 space-y-5">
<InfoRow label="Supplier Product" value={application.supplierProduct} />
<InfoRow label="Supplier Technical" referenceValue={application.supplierTechnical} jiraHost={jiraHost} />
<InfoRow label="Supplier Implementation" referenceValue={application.supplierImplementation} jiraHost={jiraHost} />
<InfoRow label="Supplier Consultancy" referenceValue={application.supplierConsultancy} jiraHost={jiraHost} />
</div>
</div>
)}
</div>
</div>
{/* Classification section */}
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden">
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setClassificationExpanded(!classificationExpanded);
}}
className="w-full px-6 py-5 flex items-center justify-between bg-gradient-to-r from-indigo-50 to-blue-50/50 hover:opacity-90 transition-all group"
>
<div className="flex items-center gap-3">
<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="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
</svg>
<h3 className="text-lg font-semibold text-gray-900">Classificatie</h3>
</div>
<svg
className={`w-5 h-5 text-gray-500 transition-transform ${classificationExpanded && 'transform rotate-180'}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{classificationExpanded && (
<div className="border-t border-gray-100">
<div className="p-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<InfoRow
label="Dynamics Factor"
referenceValue={application.dynamicsFactor}
infoDescription={application.dynamicsFactor?.summary || application.dynamicsFactor?.description || undefined}
jiraHost={jiraHost}
/>
<InfoRow
label="Complexity Factor"
referenceValue={application.complexityFactor}
infoDescription={application.complexityFactor?.summary || application.complexityFactor?.description || undefined}
jiraHost={jiraHost}
/>
<InfoRow label="Number of Users" referenceValue={application.numberOfUsers} jiraHost={jiraHost} />
</div>
{/* FTE - Benodigde inspanning applicatiemanagement */}
<div className="mt-8 pt-6 border-t-2 border-gray-200 bg-gradient-to-br from-blue-50/50 to-indigo-50/50 rounded-xl p-6">
<div className="flex items-center gap-2 mb-4">
<div className="w-8 h-8 bg-gradient-to-br from-blue-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="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>
<label className="block text-sm font-semibold text-gray-900">
Benodigde inspanning applicatiemanagement
</label>
</div>
<div className="w-full border-2 border-blue-200 rounded-xl px-4 py-3 bg-white shadow-sm">
<EffortDisplay
effectiveFte={getEffectiveFte(calculatedFte, application.overrideFTE, application.requiredEffortApplicationManagement)}
calculatedFte={calculatedFte ?? application.requiredEffortApplicationManagement ?? null}
overrideFte={application.overrideFTE ?? null}
breakdown={effortBreakdown}
isPreview={false}
showDetails={true}
showOverrideInput={false}
/>
</div>
<p className="mt-3 text-xs text-gray-600 leading-relaxed">
Automatisch berekend op basis van Regiemodel, Application Type, Business Impact Analyse en Hosting (v25)
</p>
</div>
</div>
</div>
)}
</div>
{/* Related Objects Sections */}
<div style={{ marginTop: '2.5rem' }} className="space-y-4">
<div className="flex items-center gap-3 mb-4">
<div className="inline-flex items-center justify-center w-10 h-10 bg-gradient-to-br from-indigo-500 to-purple-600 rounded-xl shadow-lg">
<svg className="w-6 h-6 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>
<h2 className="text-xl font-bold text-gray-900">Gerelateerde objecten</h2>
</div>
{RELATED_OBJECTS_CONFIG.map((config) => {
const data = relatedObjects.get(config.objectType);
const isExpanded = expandedSections.has(config.objectType);
const colors = COLOR_SCHEMES[config.colorScheme];
const objects = data?.objects || [];
const count = objects.length;
const isLoading = data?.loading ?? true;
return (
<div key={config.objectType} className={clsx('bg-white rounded-2xl shadow-sm border overflow-hidden transition-all hover:shadow-md', colors.border)}>
{/* Header - clickable to expand/collapse */}
<button
onClick={() => toggleSection(config.objectType)}
className={clsx(
'w-full px-6 py-5 flex items-center justify-between',
colors.header,
'hover:opacity-90 transition-all group'
)}
>
<div className="flex items-center gap-3">
<span className={colors.icon}>{config.icon}</span>
<h3 className="text-lg font-medium text-gray-900">{config.title}</h3>
{isLoading ? (
<div className="animate-spin rounded-full h-4 w-4 border-2 border-gray-300 border-t-gray-600" />
) : (
<span className={clsx('px-2 py-0.5 rounded-full text-xs font-medium', colors.badge)}>
{count}
</span>
)}
</div>
<svg
className={clsx(
'w-5 h-5 text-gray-500 transition-transform',
isExpanded && 'transform rotate-180'
)}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{/* Content */}
{isExpanded && (
<div className="border-t border-gray-100">
{isLoading ? (
<div className="p-8 text-center">
<div className="inline-flex items-center justify-center w-12 h-12 bg-blue-100 rounded-full mb-3">
<svg className="animate-spin h-6 w-6 text-blue-600" 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>
</div>
<p className="text-gray-600 text-sm font-medium">Laden...</p>
</div>
) : data?.error ? (
<div className="p-6 text-center">
<div className="inline-flex items-center justify-center w-12 h-12 bg-red-100 rounded-full mb-3">
<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 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<p className="text-red-600 text-sm font-medium">{data.error}</p>
</div>
) : count === 0 ? (
<div className="p-8 text-center">
<div className="inline-flex items-center justify-center w-12 h-12 bg-gray-100 rounded-full mb-3">
<svg className="w-6 h-6 text-gray-400" 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>
</div>
<p className="text-sm font-medium text-gray-500">Geen {config.title.toLowerCase()} gevonden</p>
<p className="text-xs text-gray-400 mt-1">Er zijn geen gerelateerde {config.title.toLowerCase()} gekoppeld aan deze applicatie</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gradient-to-r from-gray-50 to-gray-100/50">
<tr>
{config.columns.map((col) => (
<th
key={col.key}
className="px-6 py-4 text-left text-xs font-semibold text-gray-700 uppercase tracking-wider border-b border-gray-200"
>
{col.label}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-100 bg-white">
{objects.map((obj) => (
<tr key={obj.id} className="hover:bg-blue-50/50 transition-colors">
{config.columns.map((col) => (
<td key={col.key} className="px-6 py-4 text-sm text-gray-900">
{col.isName && jiraHost ? (
<a
href={`${jiraHost}/secure/insight/assets/${obj.key}`}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 hover:underline inline-flex items-center gap-1.5 font-medium group"
>
{obj.attributes[col.key] || obj.name || '-'}
<svg className="w-4 h-4 transition-transform group-hover:translate-x-0.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>
) : (
<span className="text-gray-700">{obj.attributes[col.key] || <span className="text-gray-400 italic">-</span>}</span>
)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</div>
);
})}
</div>
{/* Call to action */}
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 border border-blue-200 rounded-2xl shadow-sm overflow-hidden">
<div className="p-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h3 className="text-lg font-semibold text-blue-900">Classificatie aanpassen?</h3>
<p className="text-blue-700 text-sm mt-1">
Bewerk applicatiefuncties, classificatie en regiemodel met AI-ondersteuning.
</p>
</div>
<Link
to={`/application/${id}/edit`}
className="inline-flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white rounded-xl transition-all font-semibold whitespace-nowrap shadow-lg shadow-blue-500/30 hover:shadow-xl hover:shadow-blue-500/40"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
Bewerken
</Link>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
// 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<HTMLDivElement>(null);
const tooltipRef = useRef<HTMLDivElement>(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 (
<>
<div
ref={wrapperRef}
className="relative inline-flex"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{children}
</div>
{isVisible && (
<div
ref={tooltipRef}
className="fixed left-0 top-full mt-3 w-80 rounded-xl shadow-2xl border border-gray-200 p-4 text-left z-50 max-h-64 overflow-y-auto"
style={{
top: `${position.top}px`,
left: `${position.left}px`,
pointerEvents: 'auto',
backgroundColor: '#ffffff',
transform: 'translateX(-50%)',
wordWrap: 'break-word',
wordBreak: 'break-word',
}}
onClick={(e) => e.stopPropagation()}
>
{/* Arrow pointer (exact same style as Team-indeling) */}
<div
className="absolute -top-2 left-4 w-0 h-0 border-l-8 border-r-8 border-b-8 border-transparent"
style={{
borderBottomColor: '#ffffff',
filter: 'drop-shadow(0 -1px 1px rgba(0,0,0,0.1))',
}}
/>
{/* Description text (exact same styling as Team-indeling remarks) */}
<div className="text-xs text-gray-600 whitespace-pre-wrap leading-relaxed">
{text}
</div>
</div>
)}
</>
);
}
// Helper component for info icon with tooltip
function InfoIcon({ description }: { description?: string | null }) {
if (!description || description.trim() === '') return null;
return (
<Tooltip text={description}>
<span className="inline-flex items-center cursor-help">
<svg
className="w-4 h-4 text-gray-400 hover:text-gray-600 transition-colors"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<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>
</span>
</Tooltip>
);
}
// Helper component for external link icon
function ExternalLinkIcon({ className = "w-4 h-4" }: { className?: string }) {
return (
<svg className={className} 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>
);
}
// 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 (
<div className="group">
<div className="mb-2">
<label className="block text-xs font-semibold text-gray-500 uppercase tracking-wider">{label}</label>
</div>
<div className="flex items-center gap-2">
<p className="text-base font-medium text-gray-900 leading-relaxed">
{displayValue || <span className="text-gray-400 italic">Niet ingevuld</span>}
</p>
{/* Info icon with description tooltip - shown when description exists */}
{description && (
<span className="inline-flex items-center">
<InfoIcon description={description} />
</span>
)}
{/* External link to Jira Assets - shown when objectId exists */}
{objectId && jiraHost && (
<Tooltip text="Openen in Jira Assets">
<a
href={`${jiraHost}/secure/insight/assets/ICMT-${objectId}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center text-gray-400 hover:text-blue-600 transition-colors"
>
<ExternalLinkIcon />
</a>
</Tooltip>
)}
</div>
</div>
);
}