- Remove unused variables in ApplicationInfo, ArchitectureDebugPage - Fix type errors in Dashboard, GovernanceAnalysis, GovernanceModelHelper (PageHeader description prop) - Add null checks and explicit types in DataValidationDashboard - Fix ObjectDetailModal type errors for _jiraCreatedAt and Date constructor - Remove unused imports and variables in SchemaConfigurationSettings - Update PageHeader to accept string | ReactNode for description prop
1301 lines
64 KiB
TypeScript
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 {
|
|
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>
|
|
);
|
|
}
|