Initial commit: ZiRA Classification Tool for Zuyderland CMDB

This commit is contained in:
2026-01-06 15:32:28 +01:00
commit 0b27adc2fb
55 changed files with 24310 additions and 0 deletions

View File

@@ -0,0 +1,682 @@
import { useEffect, useState, useCallback } from 'react';
import { useNavigate, useSearchParams, Link } from 'react-router-dom';
import { clsx } from 'clsx';
import { searchApplications, getReferenceData } from '../services/api';
import { useSearchStore } from '../stores/searchStore';
import { useNavigationStore } from '../stores/navigationStore';
import type { ApplicationListItem, SearchResult, ReferenceValue, ApplicationStatus } from '../types';
const ALL_STATUSES: ApplicationStatus[] = [
'In Production',
'Implementation',
'Proof of Concept',
'End of support',
'End of life',
'Deprecated',
'Shadow IT',
'Closed',
'Undefined',
];
export default function ApplicationList() {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const {
filters,
currentPage,
pageSize,
setSearchText,
setStatuses,
setApplicationFunction,
setGovernanceModel,
setApplicationCluster,
setApplicationType,
setOrganisation,
setHostingType,
setBusinessImportance,
setCurrentPage,
resetFilters,
} = useSearchStore();
const { setNavigationContext } = useNavigationStore();
const [result, setResult] = useState<SearchResult | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [organisations, setOrganisations] = useState<ReferenceValue[]>([]);
const [hostingTypes, setHostingTypes] = useState<ReferenceValue[]>([]);
const [businessImportanceOptions, setBusinessImportanceOptions] = useState<ReferenceValue[]>([]);
const [showFilters, setShowFilters] = useState(true);
// Sync URL params with store on mount
useEffect(() => {
const pageParam = searchParams.get('page');
if (pageParam) {
const page = parseInt(pageParam, 10);
if (!isNaN(page) && page > 0 && page !== currentPage) {
setCurrentPage(page);
}
}
}, []); // Only run on mount
// Update URL when page changes
useEffect(() => {
const currentUrlPage = searchParams.get('page');
const currentUrlPageNum = currentUrlPage ? parseInt(currentUrlPage, 10) : 1;
if (currentPage !== currentUrlPageNum) {
if (currentPage === 1) {
// Remove page param when on page 1
searchParams.delete('page');
} else {
searchParams.set('page', currentPage.toString());
}
setSearchParams(searchParams, { replace: true });
}
}, [currentPage, searchParams, setSearchParams]);
const fetchApplications = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await searchApplications(filters, currentPage, pageSize);
setResult(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load applications');
} finally {
setLoading(false);
}
}, [filters, currentPage, pageSize]);
useEffect(() => {
fetchApplications();
}, [fetchApplications]);
useEffect(() => {
async function loadReferenceData() {
try {
const data = await getReferenceData();
setOrganisations(data.organisations);
setHostingTypes(data.hostingTypes);
setBusinessImportanceOptions(data.businessImportance || []);
} catch (err) {
console.error('Failed to load reference data', err);
}
}
loadReferenceData();
}, []);
// Update navigation context whenever results change, so "Opslaan & Volgende" works
// even when user opens an application in a new tab
useEffect(() => {
if (result && result.applications.length > 0) {
const allIds = result.applications.map((a) => a.id);
// Preserve current index if it's still valid, otherwise reset to 0
setNavigationContext(allIds, filters, 0);
}
}, [result, filters, setNavigationContext]);
const handleRowClick = (app: ApplicationListItem, index: number, event: React.MouseEvent) => {
// Update current index in navigation context
if (result) {
const allIds = result.applications.map((a) => a.id);
setNavigationContext(allIds, filters, index);
}
// Let the browser handle CTRL+click / CMD+click / middle-click natively for new tab
// Only navigate programmatically for regular clicks
if (!event.ctrlKey && !event.metaKey && !event.shiftKey && event.button === 0) {
event.preventDefault();
navigate(`/applications/${app.id}`);
}
};
const toggleStatus = (status: ApplicationStatus) => {
const current = filters.statuses || [];
if (current.includes(status)) {
setStatuses(current.filter((s) => s !== status));
} else {
setStatuses([...current, status]);
}
};
return (
<div className="space-y-6">
{/* Page header */}
<div className="flex justify-between items-center">
<div>
<h2 className="text-2xl font-bold text-gray-900">Applicaties</h2>
<p className="text-gray-600">Zoek en classificeer applicatiecomponenten</p>
</div>
<button
onClick={() => setShowFilters(!showFilters)}
className="btn btn-secondary"
>
{showFilters ? 'Verberg filters' : 'Toon filters'}
</button>
</div>
{/* Search and filters */}
<div className="card">
{/* Search bar */}
<div className="p-4 border-b border-gray-200">
<div className="relative">
<input
type="text"
placeholder="Zoeken op naam, beschrijving, leverancier..."
value={filters.searchText || ''}
onChange={(e) => setSearchText(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
<svg
className="absolute left-3 top-2.5 h-5 w-5 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</div>
</div>
{/* Filters */}
{showFilters && (
<div className="p-4 bg-gray-50 border-b border-gray-200">
<div className="flex justify-between items-center mb-4">
<h3 className="font-medium text-gray-900">Filters</h3>
<button
onClick={resetFilters}
className="text-sm text-blue-600 hover:text-blue-800"
>
Wis alle filters
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Status filter */}
<div>
<label className="label mb-2">Status</label>
<div className="space-y-1 max-h-48 overflow-y-auto">
{ALL_STATUSES.map((status) => (
<label key={status} className="flex items-center space-x-2">
<input
type="checkbox"
checked={(filters.statuses || []).includes(status)}
onChange={() => toggleStatus(status)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700">{status}</span>
</label>
))}
</div>
</div>
{/* Classification filters */}
<div className="space-y-4">
<div>
<label className="label mb-2">ApplicationFunction</label>
<div className="space-y-1">
{(['all', 'filled', 'empty'] as const).map((value) => (
<label key={value} className="flex items-center space-x-2">
<input
type="radio"
name="applicationFunction"
checked={filters.applicationFunction === value}
onChange={() => setApplicationFunction(value)}
className="border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700">
{value === 'all' ? 'Alle' : value === 'filled' ? 'Ingevuld' : 'Leeg'}
</span>
</label>
))}
</div>
</div>
<div>
<label className="label mb-2">Governance Model</label>
<div className="space-y-1">
{(['all', 'filled', 'empty'] as const).map((value) => (
<label key={value} className="flex items-center space-x-2">
<input
type="radio"
name="governanceModel"
checked={filters.governanceModel === value}
onChange={() => setGovernanceModel(value)}
className="border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700">
{value === 'all' ? 'Alle' : value === 'filled' ? 'Ingevuld' : 'Leeg'}
</span>
</label>
))}
</div>
</div>
<div>
<label className="label mb-2">Application Cluster</label>
<div className="space-y-1">
{(['all', 'filled', 'empty'] as const).map((value) => (
<label key={value} className="flex items-center space-x-2">
<input
type="radio"
name="applicationCluster"
checked={filters.applicationCluster === value}
onChange={() => setApplicationCluster(value)}
className="border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700">
{value === 'all' ? 'Alle' : value === 'filled' ? 'Ingevuld' : 'Leeg'}
</span>
</label>
))}
</div>
</div>
<div>
<label className="label mb-2">Application Type</label>
<div className="space-y-1">
{(['all', 'filled', 'empty'] as const).map((value) => (
<label key={value} className="flex items-center space-x-2">
<input
type="radio"
name="applicationType"
checked={filters.applicationType === value}
onChange={() => setApplicationType(value)}
className="border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700">
{value === 'all' ? 'Alle' : value === 'filled' ? 'Ingevuld' : 'Leeg'}
</span>
</label>
))}
</div>
</div>
</div>
{/* Dropdown filters */}
<div className="space-y-4">
<div>
<label className="label mb-2">Organisatie</label>
<select
value={filters.organisation || ''}
onChange={(e) => setOrganisation(e.target.value || undefined)}
className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="">Alle organisaties</option>
{organisations.map((org) => (
<option key={org.objectId} value={org.name}>
{org.name}
</option>
))}
</select>
</div>
<div>
<label className="label mb-2">Hosting Type</label>
<select
value={filters.hostingType || ''}
onChange={(e) => setHostingType(e.target.value || undefined)}
className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="">Alle types</option>
{hostingTypes.map((type) => (
<option key={type.objectId} value={type.name}>
{type.name}
</option>
))}
</select>
</div>
<div>
<label className="label mb-2">Business Importance</label>
<select
value={filters.businessImportance || ''}
onChange={(e) => setBusinessImportance(e.target.value || undefined)}
className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="">Alle</option>
{businessImportanceOptions.map((importance) => (
<option key={importance.objectId} value={importance.name}>
{importance.name}
</option>
))}
</select>
</div>
</div>
</div>
</div>
)}
{/* Results count */}
<div className="px-4 py-3 bg-white border-b border-gray-200 flex justify-between items-center">
<span className="text-sm text-gray-600">
{result ? (
<>
Resultaten: <strong>{result.totalCount}</strong> applicaties
</>
) : (
'Laden...'
)}
</span>
</div>
{/* Results table */}
{loading ? (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
</div>
) : error ? (
<div className="p-4 text-red-600">{error}</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
#
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Naam
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
AppFunctie
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Governance
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Benodigde inspanning
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{result?.applications.map((app, index) => (
<tr
key={app.id}
className="hover:bg-blue-50 transition-colors group"
>
<td className="py-0">
<Link
to={`/applications/${app.id}`}
onClick={(e) => handleRowClick(app, index, e)}
className="block px-4 py-3 text-sm text-gray-500"
>
{(currentPage - 1) * pageSize + index + 1}
</Link>
</td>
<td className="py-0">
<Link
to={`/applications/${app.id}`}
onClick={(e) => handleRowClick(app, index, e)}
className="block px-4 py-3"
>
<div className="text-sm font-medium text-blue-600 group-hover:text-blue-800 group-hover:underline">
{app.name}
</div>
<div className="text-xs text-gray-500">{app.key}</div>
</Link>
</td>
<td className="py-0">
<Link
to={`/applications/${app.id}`}
onClick={(e) => handleRowClick(app, index, e)}
className="block px-4 py-3"
>
<StatusBadge status={app.status} />
</Link>
</td>
<td className="py-0">
<Link
to={`/applications/${app.id}`}
onClick={(e) => handleRowClick(app, index, e)}
className="block px-4 py-3"
>
{app.applicationFunctions && app.applicationFunctions.length > 0 ? (
<div className="flex flex-wrap gap-1">
{app.applicationFunctions.map((func) => (
<span
key={func.objectId}
className="inline-block px-2 py-0.5 text-xs bg-blue-100 text-blue-800 rounded"
title={func.description || func.name}
>
{func.name}{func.applicationFunctionCategory ? ` (${func.applicationFunctionCategory.name})` : ''}
</span>
))}
</div>
) : (
<span className="text-sm text-orange-600 font-medium">
Leeg
</span>
)}
</Link>
</td>
<td className="py-0">
<Link
to={`/applications/${app.id}`}
onClick={(e) => handleRowClick(app, index, e)}
className="block px-4 py-3"
>
{app.governanceModel ? (
<span className="text-sm text-gray-900">
{app.governanceModel.name}
</span>
) : (
<span className="text-sm text-orange-600 font-medium">
Leeg
</span>
)}
</Link>
</td>
<td className="py-0">
<Link
to={`/applications/${app.id}`}
onClick={(e) => handleRowClick(app, index, e)}
className="block px-4 py-3 text-sm text-gray-900"
>
{app.requiredEffortApplicationManagement !== null ? (
<span className="font-medium">
{app.requiredEffortApplicationManagement.toFixed(2)} FTE
</span>
) : (
<span className="text-gray-400">-</span>
)}
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Pagination */}
{result && result.totalPages > 1 && (
<div className="px-4 py-3 bg-gray-50 border-t border-gray-200 flex items-center justify-between">
{currentPage > 1 ? (
<Link
to={currentPage === 2 ? '/applications' : `/applications?page=${currentPage - 1}`}
onClick={() => setCurrentPage(currentPage - 1)}
className="btn btn-secondary"
>
Vorige
</Link>
) : (
<button disabled className="btn btn-secondary opacity-50 cursor-not-allowed">
Vorige
</button>
)}
<span className="text-sm text-gray-600">
Pagina {currentPage} van {result.totalPages}
</span>
{currentPage < result.totalPages ? (
<Link
to={`/applications?page=${currentPage + 1}`}
onClick={() => setCurrentPage(currentPage + 1)}
className="btn btn-secondary"
>
Volgende
</Link>
) : (
<button disabled className="btn btn-secondary opacity-50 cursor-not-allowed">
Volgende
</button>
)}
</div>
)}
</div>
</div>
);
}
export function StatusBadge({ status }: { status: string | null }) {
const statusColors: Record<string, string> = {
'Closed': 'badge-dark-red',
'Deprecated': 'badge-yellow',
'End of life': 'badge-light-red',
'End of support': 'badge-light-red',
'Implementation': 'badge-blue',
'In Production': 'badge-dark-green',
'Proof of Concept': 'badge-light-green',
'Shadow IT': 'badge-black',
'Undefined': 'badge-gray',
};
if (!status) return <span className="text-sm text-gray-400">-</span>;
return (
<span className={clsx('badge', statusColors[status] || 'badge-gray')}>
{status}
</span>
);
}
export function BusinessImportanceBadge({ importance }: { importance: string | null }) {
// Helper function to get the number prefix from the importance string
const getImportanceNumber = (value: string | null): string | null => {
if (!value) return null;
// Match patterns like "0 - Critical Infrastructure" or just "0"
const match = value.match(/^(\d+)/);
return match ? match[1] : null;
};
const importanceNumber = getImportanceNumber(importance);
// Map importance number to icon type and color
const getImportanceConfig = (num: string | null) => {
switch (num) {
case '0':
return {
icon: 'warning',
color: 'badge-darker-red',
label: importance || '0 - Critical Infrastructure',
};
case '1':
return {
icon: 'exclamation',
color: 'badge-dark-red',
label: importance || '1 - Critical',
};
case '2':
return {
icon: 'exclamation',
color: 'badge-red',
label: importance || '2 - Highest',
};
case '3':
return {
icon: 'circle',
color: 'badge-yellow-orange',
label: importance || '3 - High',
};
case '4':
return {
icon: 'circle',
color: 'badge-dark-blue',
label: importance || '4 - Medium',
};
case '5':
return {
icon: 'circle',
color: 'badge-light-blue',
label: importance || '5 - Low',
};
case '6':
return {
icon: 'circle',
color: 'badge-lighter-blue',
label: importance || '6 - Lowest',
};
case '9':
return {
icon: 'question',
color: 'badge-black',
label: importance || '9 - Unknown',
};
default:
return {
icon: null,
color: 'badge-gray',
label: importance || '-',
};
}
};
if (!importance) return <span className="text-sm text-gray-400">-</span>;
const config = getImportanceConfig(importanceNumber);
// Icon components
const WarningIcon = () => (
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
);
const ExclamationIcon = () => (
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
);
const CircleIcon = () => (
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<circle cx="10" cy="10" r="8" />
</svg>
);
const QuestionIcon = () => (
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-3a1 1 0 00-.867.5 1 1 0 11-1.731-1A3 3 0 0113 8a3.001 3.001 0 01-2 2.83V11a1 1 0 11-2 0v-1a1 1 0 011-1 1 1 0 100-2zm0 8a1 1 0 100-2 1 1 0 000 2z" clipRule="evenodd" />
</svg>
);
const renderIcon = () => {
switch (config.icon) {
case 'warning':
return <WarningIcon />;
case 'exclamation':
return <ExclamationIcon />;
case 'circle':
return <CircleIcon />;
case 'question':
return <QuestionIcon />;
default:
return null;
}
};
return (
<span className={clsx('badge inline-flex items-center gap-1.5', config.color)}>
{renderIcon()}
<span>{config.label}</span>
</span>
);
}