Initial commit: ZiRA Classification Tool for Zuyderland CMDB
This commit is contained in:
16
frontend/Dockerfile
Normal file
16
frontend/Dockerfile
Normal file
@@ -0,0 +1,16 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
# Copy source
|
||||
COPY . .
|
||||
|
||||
# Expose port
|
||||
EXPOSE 5173
|
||||
|
||||
# Start development server
|
||||
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="nl">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ZiRA Classificatie Tool - Zuyderland</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
27
frontend/package.json
Normal file
27
frontend/package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "zira-frontend",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.28.0",
|
||||
"zustand": "^5.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.47",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^7.3.0"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
84
frontend/src/App.tsx
Normal file
84
frontend/src/App.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { Routes, Route, Link, useLocation } from 'react-router-dom';
|
||||
import { clsx } from 'clsx';
|
||||
import Dashboard from './components/Dashboard';
|
||||
import ApplicationList from './components/ApplicationList';
|
||||
import ApplicationDetail from './components/ApplicationDetail';
|
||||
import TeamDashboard from './components/TeamDashboard';
|
||||
import Configuration from './components/Configuration';
|
||||
import ConfigurationV25 from './components/ConfigurationV25';
|
||||
|
||||
function App() {
|
||||
const location = useLocation();
|
||||
|
||||
const navItems = [
|
||||
{ path: '/', label: 'Dashboard', exact: true },
|
||||
{ path: '/applications', label: 'Applicaties', exact: false },
|
||||
{ path: '/teams', label: 'Team-indeling', exact: true },
|
||||
{ path: '/configuration', label: 'FTE Config v25', exact: true },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
{/* Header */}
|
||||
<header className="bg-white shadow-sm border-b border-gray-200">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center h-16">
|
||||
<div className="flex items-center space-x-8">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center">
|
||||
<span className="text-white font-bold text-sm">ZiRA</span>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-gray-900">
|
||||
Classificatie Tool
|
||||
</h1>
|
||||
<p className="text-xs text-gray-500">Zuyderland CMDB</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="hidden md:flex space-x-1">
|
||||
{navItems.map((item) => {
|
||||
const isActive = item.exact
|
||||
? location.pathname === item.path
|
||||
: location.pathname.startsWith(item.path);
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={clsx(
|
||||
'px-3 py-2 rounded-md text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-blue-50 text-blue-700'
|
||||
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<span className="text-sm text-gray-500">ICMT</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/applications" element={<ApplicationList />} />
|
||||
<Route path="/applications/:id" element={<ApplicationDetail />} />
|
||||
<Route path="/teams" element={<TeamDashboard />} />
|
||||
<Route path="/configuration" element={<ConfigurationV25 />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
2555
frontend/src/components/ApplicationDetail.tsx
Normal file
2555
frontend/src/components/ApplicationDetail.tsx
Normal file
File diff suppressed because it is too large
Load Diff
682
frontend/src/components/ApplicationList.tsx
Normal file
682
frontend/src/components/ApplicationList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
809
frontend/src/components/Configuration.tsx
Normal file
809
frontend/src/components/Configuration.tsx
Normal file
@@ -0,0 +1,809 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getEffortCalculationConfig, updateEffortCalculationConfig, getApplicationManagementHosting, getApplicationTypes, type EffortCalculationConfig } from '../services/api';
|
||||
import type { ReferenceValue } from '../types';
|
||||
|
||||
export default function Configuration() {
|
||||
const [config, setConfig] = useState<EffortCalculationConfig | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const [applicationManagementHosting, setApplicationManagementHosting] = useState<ReferenceValue[]>([]);
|
||||
const [applicationTypes, setApplicationTypes] = useState<ReferenceValue[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
loadConfig();
|
||||
loadReferenceData();
|
||||
}, []);
|
||||
|
||||
const loadReferenceData = async () => {
|
||||
try {
|
||||
const [hostingData, applicationTypesData] = await Promise.all([
|
||||
getApplicationManagementHosting(),
|
||||
getApplicationTypes(),
|
||||
]);
|
||||
setApplicationManagementHosting(hostingData);
|
||||
setApplicationTypes(applicationTypesData);
|
||||
} catch (err) {
|
||||
console.error('Failed to load reference data:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const data = await getEffortCalculationConfig();
|
||||
setConfig(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load configuration');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!config) return;
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
await updateEffortCalculationConfig(config);
|
||||
setSuccess('Configuration saved successfully!');
|
||||
setTimeout(() => setSuccess(null), 3000);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to save configuration');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateDefaultResult = (value: number) => {
|
||||
if (!config) return;
|
||||
setConfig({
|
||||
...config,
|
||||
default: { result: value },
|
||||
});
|
||||
};
|
||||
|
||||
const updateGovernanceModelRule = (index: number, updates: Partial<EffortCalculationConfig['governanceModelRules'][0]>) => {
|
||||
if (!config) return;
|
||||
const newRules = [...config.governanceModelRules];
|
||||
newRules[index] = { ...newRules[index], ...updates };
|
||||
setConfig({ ...config, governanceModelRules: newRules });
|
||||
};
|
||||
|
||||
const addGovernanceModelRule = () => {
|
||||
if (!config) return;
|
||||
setConfig({
|
||||
...config,
|
||||
governanceModelRules: [
|
||||
...config.governanceModelRules,
|
||||
{
|
||||
governanceModel: 'New Governance Model',
|
||||
applicationTypeRules: {},
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const removeGovernanceModelRule = (index: number) => {
|
||||
if (!config) return;
|
||||
const newRules = config.governanceModelRules.filter((_, i) => i !== index);
|
||||
setConfig({ ...config, governanceModelRules: newRules });
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-gray-500">Loading configuration...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
return (
|
||||
<div className="text-red-500">Failed to load configuration</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Basis FTE Configuration</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Configure the Required Effort Application Management calculation rules
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<button
|
||||
onClick={loadConfig}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
|
||||
>
|
||||
Reload
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save Configuration'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-4 bg-red-50 border border-red-200 rounded-md">
|
||||
<p className="text-sm text-red-800">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="p-4 bg-green-50 border border-green-200 rounded-md">
|
||||
<p className="text-sm text-green-800">{success}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Default Result */}
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Default Result</h2>
|
||||
<div className="flex items-center space-x-4">
|
||||
<label className="text-sm font-medium text-gray-700">Result (FTE):</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={config.default.result}
|
||||
onChange={(e) => updateDefaultResult(parseFloat(e.target.value) || 0)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Governance Model Rules */}
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Governance Model Rules</h2>
|
||||
<button
|
||||
onClick={addGovernanceModelRule}
|
||||
className="px-3 py-1 text-sm font-medium text-blue-600 bg-blue-50 rounded-md hover:bg-blue-100"
|
||||
>
|
||||
+ Add Governance Model
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{[...config.governanceModelRules]
|
||||
.sort((a, b) => a.governanceModel.localeCompare(b.governanceModel))
|
||||
.map((rule, originalIndex) => {
|
||||
// Find the original index in the unsorted array
|
||||
const index = config.governanceModelRules.findIndex(r => r === rule);
|
||||
return (
|
||||
<GovernanceModelRuleEditor
|
||||
key={index}
|
||||
rule={rule}
|
||||
index={index}
|
||||
applicationManagementHosting={applicationManagementHosting}
|
||||
applicationTypes={applicationTypes}
|
||||
onUpdate={(updates) => updateGovernanceModelRule(index, updates)}
|
||||
onRemove={() => removeGovernanceModelRule(index)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface GovernanceModelRuleEditorProps {
|
||||
rule: EffortCalculationConfig['governanceModelRules'][0];
|
||||
index: number;
|
||||
applicationManagementHosting: ReferenceValue[];
|
||||
applicationTypes: ReferenceValue[];
|
||||
onUpdate: (updates: Partial<EffortCalculationConfig['governanceModelRules'][0]>) => void;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
function GovernanceModelRuleEditor({ rule, applicationManagementHosting, applicationTypes, onUpdate, onRemove }: GovernanceModelRuleEditorProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const updateGovernanceModel = (value: string) => {
|
||||
onUpdate({ governanceModel: value });
|
||||
};
|
||||
|
||||
const updateDefaultResult = (value: number) => {
|
||||
onUpdate({ default: { result: value } });
|
||||
};
|
||||
|
||||
const updateApplicationTypeRule = (key: string, updates: any) => {
|
||||
const newRules = { ...rule.applicationTypeRules };
|
||||
if (updates === null) {
|
||||
delete newRules[key];
|
||||
} else {
|
||||
newRules[key] = { ...newRules[key], ...updates };
|
||||
}
|
||||
onUpdate({ applicationTypeRules: newRules });
|
||||
};
|
||||
|
||||
const addApplicationTypeRule = () => {
|
||||
const newKey = `New Application Type ${Object.keys(rule.applicationTypeRules).length + 1}`;
|
||||
const newRules = {
|
||||
...rule.applicationTypeRules,
|
||||
[newKey]: {
|
||||
applicationTypes: [],
|
||||
businessImpactRules: {},
|
||||
},
|
||||
};
|
||||
onUpdate({ applicationTypeRules: newRules });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border border-gray-200 rounded-lg">
|
||||
<div className="flex items-center justify-between p-4 bg-gray-50">
|
||||
<div className="flex items-center space-x-4">
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
{expanded ? '▼' : '▶'}
|
||||
</button>
|
||||
<input
|
||||
type="text"
|
||||
value={rule.governanceModel}
|
||||
onChange={(e) => updateGovernanceModel(e.target.value)}
|
||||
className="px-3 py-1 text-sm font-medium border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Governance Model Name"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={onRemove}
|
||||
className="px-3 py-1 text-sm font-medium text-red-600 bg-red-50 rounded-md hover:bg-red-100"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Default Result for this Governance Model */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Default Result (FTE):
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={rule.default?.result ?? 0}
|
||||
onChange={(e) => updateDefaultResult(parseFloat(e.target.value) || 0)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Application Type Rules */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Application Type Rules
|
||||
</label>
|
||||
<button
|
||||
onClick={addApplicationTypeRule}
|
||||
className="px-2 py-1 text-xs font-medium text-blue-600 bg-blue-50 rounded-md hover:bg-blue-100"
|
||||
>
|
||||
+ Add Application Type
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{Object.entries(rule.applicationTypeRules).map(([key, appTypeRule]) => (
|
||||
<ApplicationTypeRuleEditor
|
||||
key={key}
|
||||
ruleKey={key}
|
||||
rule={appTypeRule}
|
||||
applicationManagementHosting={applicationManagementHosting}
|
||||
applicationTypes={applicationTypes}
|
||||
onUpdate={(updates) => updateApplicationTypeRule(key, updates)}
|
||||
onRemove={() => updateApplicationTypeRule(key, null)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ApplicationTypeRuleEditorProps {
|
||||
ruleKey: string;
|
||||
rule: EffortCalculationConfig['governanceModelRules'][0]['applicationTypeRules'][string];
|
||||
applicationManagementHosting: ReferenceValue[];
|
||||
applicationTypes: ReferenceValue[];
|
||||
onUpdate: (updates: any) => void;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
function ApplicationTypeRuleEditor({ ruleKey, rule, applicationManagementHosting, applicationTypes, onUpdate, onRemove }: ApplicationTypeRuleEditorProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
// Check if rule is a simple EffortRule or ApplicationTypeRule
|
||||
const isSimpleRule = 'result' in rule && !('applicationTypes' in rule);
|
||||
|
||||
if (isSimpleRule) {
|
||||
// Simple EffortRule
|
||||
return (
|
||||
<div className="border border-gray-200 rounded-md p-3 bg-gray-50">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<span className="text-sm font-medium text-gray-700">{ruleKey}:</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={rule.result}
|
||||
onChange={(e) => onUpdate({ result: parseFloat(e.target.value) || 0 })}
|
||||
className="px-2 py-1 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-500">FTE</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onRemove}
|
||||
className="px-2 py-1 text-xs font-medium text-red-600 bg-red-50 rounded-md hover:bg-red-100"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Full ApplicationTypeRule
|
||||
const updateApplicationTypes = (selectedTypes: string[]) => {
|
||||
if (selectedTypes.length === 0) {
|
||||
// If no types selected, remove the entire rule
|
||||
onRemove();
|
||||
} else {
|
||||
onUpdate({ applicationTypes: selectedTypes.length === 1 ? selectedTypes[0] : selectedTypes });
|
||||
}
|
||||
};
|
||||
|
||||
const updateBusinessImpactRule = (key: string, updates: any) => {
|
||||
const newRules = { ...rule.businessImpactRules };
|
||||
if (updates === null) {
|
||||
delete newRules[key];
|
||||
} else {
|
||||
newRules[key] = updates;
|
||||
}
|
||||
onUpdate({ businessImpactRules: newRules });
|
||||
};
|
||||
|
||||
const addBusinessImpactRule = () => {
|
||||
// Find the next available Business Impact level (F, E, D, C, B, A)
|
||||
const availableLevels = ['F', 'E', 'D', 'C', 'B', 'A'];
|
||||
const existingKeys = Object.keys(rule.businessImpactRules);
|
||||
const nextLevel = availableLevels.find(level => !existingKeys.includes(level));
|
||||
|
||||
if (nextLevel) {
|
||||
const newRules = {
|
||||
...rule.businessImpactRules,
|
||||
[nextLevel]: { result: 0.1 },
|
||||
};
|
||||
onUpdate({ businessImpactRules: newRules });
|
||||
}
|
||||
};
|
||||
|
||||
const updateDefaultRule = (updates: any) => {
|
||||
onUpdate({ default: updates });
|
||||
};
|
||||
|
||||
const selectedApplicationTypeNames = Array.isArray(rule.applicationTypes)
|
||||
? rule.applicationTypes
|
||||
: rule.applicationTypes ? [rule.applicationTypes] : [];
|
||||
|
||||
return (
|
||||
<div className="border border-gray-200 rounded-md">
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50">
|
||||
<div className="flex items-center space-x-3 flex-1">
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
{expanded ? '▼' : '▶'}
|
||||
</button>
|
||||
<div className="flex-1">
|
||||
<MultiSelect
|
||||
options={applicationTypes.map(at => at.name)}
|
||||
selected={selectedApplicationTypeNames}
|
||||
onChange={updateApplicationTypes}
|
||||
placeholder="Select Application Types"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onRemove}
|
||||
className="px-2 py-1 text-xs font-medium text-red-600 bg-red-50 rounded-md hover:bg-red-100 ml-2"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="p-3 space-y-3">
|
||||
{/* Business Impact Rules */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Business Impact Rules
|
||||
</label>
|
||||
<button
|
||||
onClick={addBusinessImpactRule}
|
||||
disabled={Object.keys(rule.businessImpactRules).length >= 6}
|
||||
className="px-2 py-1 text-xs font-medium text-blue-600 bg-blue-50 rounded-md hover:bg-blue-100 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title={Object.keys(rule.businessImpactRules).length >= 6 ? 'All Business Impact levels (F, E, D, C, B, A) are already added' : 'Add next available Business Impact level'}
|
||||
>
|
||||
+ Add Business Impact
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{Object.entries(rule.businessImpactRules).map(([key, impactRule]) => (
|
||||
<BusinessImpactRuleEditor
|
||||
key={key}
|
||||
ruleKey={key}
|
||||
rule={impactRule}
|
||||
applicationManagementHosting={applicationManagementHosting}
|
||||
onUpdate={(updates) => updateBusinessImpactRule(key, updates)}
|
||||
onRemove={() => updateBusinessImpactRule(key, null)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Default Rule */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Default Rule
|
||||
</label>
|
||||
{!rule.default && (
|
||||
<button
|
||||
onClick={() => updateDefaultRule({ result: 0.1 })}
|
||||
className="px-2 py-1 text-xs font-medium text-blue-600 bg-blue-50 rounded-md hover:bg-blue-100"
|
||||
>
|
||||
+ Add Default Rule
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{rule.default ? (
|
||||
Array.isArray(rule.default) ? (
|
||||
<div className="space-y-2">
|
||||
{rule.default.map((r, index) => (
|
||||
<EffortRuleEditor
|
||||
key={index}
|
||||
rule={r}
|
||||
applicationManagementHosting={applicationManagementHosting}
|
||||
onUpdate={(updates) => {
|
||||
const newRules = [...rule.default as any[]];
|
||||
newRules[index] = { ...newRules[index], ...updates };
|
||||
updateDefaultRule(newRules);
|
||||
}}
|
||||
onRemove={rule.default.length > 1 ? () => {
|
||||
const newRules = (rule.default as any[]).filter((_, i) => i !== index);
|
||||
updateDefaultRule(newRules.length === 1 ? newRules[0] : newRules);
|
||||
} : undefined}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
onClick={() => {
|
||||
const newRules = [...(rule.default as any[]), { result: 0.1 }];
|
||||
updateDefaultRule(newRules);
|
||||
}}
|
||||
className="px-2 py-1 text-xs font-medium text-blue-600 bg-blue-50 rounded-md hover:bg-blue-100"
|
||||
>
|
||||
+ Add Rule
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<EffortRuleEditor
|
||||
rule={rule.default}
|
||||
applicationManagementHosting={applicationManagementHosting}
|
||||
onUpdate={updateDefaultRule}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<p className="text-sm text-gray-500 italic">No default rule set</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface BusinessImpactRuleEditorProps {
|
||||
ruleKey: string;
|
||||
rule: EffortCalculationConfig['governanceModelRules'][0]['applicationTypeRules'][string]['businessImpactRules'][string];
|
||||
applicationManagementHosting: ReferenceValue[];
|
||||
onUpdate: (updates: any) => void;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
function BusinessImpactRuleEditor({ ruleKey, rule, applicationManagementHosting, onUpdate, onRemove }: BusinessImpactRuleEditorProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const isArray = Array.isArray(rule);
|
||||
|
||||
if (!isArray) {
|
||||
// Simple EffortRule - convert to array format to support hosting type differentiation
|
||||
return (
|
||||
<div className="border border-gray-200 rounded-md">
|
||||
<div className="flex items-center justify-between p-2 bg-gray-50">
|
||||
<div className="flex items-center space-x-3">
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
{expanded ? '▼' : '▶'}
|
||||
</button>
|
||||
<span className="text-sm font-medium text-gray-700">{ruleKey}:</span>
|
||||
{!expanded && (
|
||||
<>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={rule.result}
|
||||
onChange={(e) => onUpdate({ result: parseFloat(e.target.value) || 0 })}
|
||||
className="px-2 py-1 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-500">FTE</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={onRemove}
|
||||
className="px-2 py-1 text-xs font-medium text-red-600 bg-red-50 rounded-md hover:bg-red-100"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
{expanded && (
|
||||
<div className="p-2 space-y-2">
|
||||
<EffortRuleEditor
|
||||
rule={rule}
|
||||
applicationManagementHosting={applicationManagementHosting}
|
||||
onUpdate={onUpdate}
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
// Convert to array format to support multiple Application Management - Hosting rules
|
||||
const newRules = [{ ...rule }, { result: 0.1 }];
|
||||
onUpdate(newRules);
|
||||
}}
|
||||
className="px-2 py-1 text-xs font-medium text-blue-600 bg-blue-50 rounded-md hover:bg-blue-100"
|
||||
>
|
||||
+ Add Application Management - Hosting Rule
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Array of EffortRules
|
||||
return (
|
||||
<div className="border border-gray-200 rounded-md">
|
||||
<div className="flex items-center justify-between p-2 bg-gray-50">
|
||||
<div className="flex items-center space-x-3">
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
{expanded ? '▼' : '▶'}
|
||||
</button>
|
||||
<span className="text-sm font-medium text-gray-700">{ruleKey} ({rule.length} rules)</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onRemove}
|
||||
className="px-2 py-1 text-xs font-medium text-red-600 bg-red-50 rounded-md hover:bg-red-100"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="p-2 space-y-2">
|
||||
{rule.map((r, index) => {
|
||||
const isDefault = !r.conditions?.applicationManagementHosting ||
|
||||
(Array.isArray(r.conditions.applicationManagementHosting) && r.conditions.applicationManagementHosting.length === 0);
|
||||
const isLastDefault = isDefault && index === rule.length - 1;
|
||||
return (
|
||||
<div key={index} className={isLastDefault ? 'border-l-4 border-blue-500 pl-2' : ''}>
|
||||
{isLastDefault && (
|
||||
<div className="text-xs font-medium text-blue-600 mb-1">Default Rule (no Application Management - Hosting match)</div>
|
||||
)}
|
||||
<EffortRuleEditor
|
||||
rule={r}
|
||||
applicationManagementHosting={applicationManagementHosting}
|
||||
onUpdate={(updates) => {
|
||||
const newRules = [...rule];
|
||||
newRules[index] = { ...newRules[index], ...updates };
|
||||
onUpdate(newRules);
|
||||
}}
|
||||
onRemove={rule.length > 1 ? () => {
|
||||
const newRules = rule.filter((_, i) => i !== index);
|
||||
onUpdate(newRules.length === 1 ? newRules[0] : newRules);
|
||||
} : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
onClick={() => {
|
||||
const newRules = [...rule, { result: 0.1 }];
|
||||
onUpdate(newRules);
|
||||
}}
|
||||
className="px-2 py-1 text-xs font-medium text-blue-600 bg-blue-50 rounded-md hover:bg-blue-100"
|
||||
>
|
||||
+ Add Application Management - Hosting Rule
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface EffortRuleEditorProps {
|
||||
rule: {
|
||||
result: number;
|
||||
conditions?: {
|
||||
applicationManagementHosting?: string | string[];
|
||||
};
|
||||
};
|
||||
applicationManagementHosting: ReferenceValue[];
|
||||
onUpdate: (updates: any) => void;
|
||||
onRemove?: () => void;
|
||||
}
|
||||
|
||||
function EffortRuleEditor({ rule, applicationManagementHosting, onUpdate, onRemove }: EffortRuleEditorProps) {
|
||||
const selectedHostingTypeNames = rule.conditions?.applicationManagementHosting
|
||||
? Array.isArray(rule.conditions.applicationManagementHosting)
|
||||
? rule.conditions.applicationManagementHosting
|
||||
: [rule.conditions.applicationManagementHosting]
|
||||
: [];
|
||||
|
||||
const updateHostingTypes = (selectedTypes: string[]) => {
|
||||
onUpdate({
|
||||
conditions: {
|
||||
...rule.conditions,
|
||||
applicationManagementHosting: selectedTypes.length === 1 ? selectedTypes[0] : selectedTypes.length > 0 ? selectedTypes : undefined,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border border-gray-200 rounded-md p-2 bg-white">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3 flex-1">
|
||||
<label className="text-sm font-medium text-gray-700">Result (FTE):</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={rule.result}
|
||||
onChange={(e) => onUpdate({ result: parseFloat(e.target.value) || 0 })}
|
||||
className="px-2 py-1 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
{onRemove && (
|
||||
<button
|
||||
onClick={onRemove}
|
||||
className="px-2 py-1 text-xs font-medium text-red-600 bg-red-50 rounded-md hover:bg-red-100 ml-2"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Application Management - Hosting (optional):</label>
|
||||
<MultiSelect
|
||||
options={applicationManagementHosting.map(ht => ht.name)}
|
||||
selected={selectedHostingTypeNames}
|
||||
onChange={updateHostingTypes}
|
||||
placeholder="Select Application Management - Hosting"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// MultiSelect Component
|
||||
interface MultiSelectProps {
|
||||
options: string[];
|
||||
selected: string[];
|
||||
onChange: (selected: string[]) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
function MultiSelect({ options, selected, onChange, placeholder = 'Select options' }: MultiSelectProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [filter, setFilter] = useState('');
|
||||
|
||||
const filteredOptions = options.filter(opt =>
|
||||
opt.toLowerCase().includes(filter.toLowerCase())
|
||||
);
|
||||
|
||||
const toggleOption = (option: string) => {
|
||||
if (selected.includes(option)) {
|
||||
onChange(selected.filter(s => s !== option));
|
||||
} else {
|
||||
onChange([...selected, option]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="w-full px-3 py-2 text-left text-sm border border-gray-300 rounded-md bg-white focus:outline-none focus:ring-2 focus:ring-blue-500 min-h-[2.5rem] flex items-center justify-between"
|
||||
>
|
||||
{selected.length === 0 ? (
|
||||
<span className="text-gray-500">{placeholder}</span>
|
||||
) : (
|
||||
<span className="text-gray-900 flex-1 text-left">
|
||||
{selected.join(', ')}
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-2 flex-shrink-0">{isOpen ? '▲' : '▼'}</span>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg max-h-64 overflow-auto">
|
||||
<div className="p-2 border-b border-gray-200">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter..."
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
className="w-full px-2 py-1 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
<div className="py-1">
|
||||
{filteredOptions.length === 0 ? (
|
||||
<p className="px-3 py-2 text-sm text-gray-500">No options found</p>
|
||||
) : (
|
||||
filteredOptions.map((option) => {
|
||||
const isSelected = selected.includes(option);
|
||||
return (
|
||||
<label
|
||||
key={option}
|
||||
className={`flex items-center px-3 py-2 cursor-pointer hover:bg-gray-50 ${
|
||||
isSelected ? 'bg-blue-50' : ''
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => toggleOption(option)}
|
||||
className="mr-3 h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<span className="text-sm text-gray-900">{option}</span>
|
||||
</label>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{isOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
529
frontend/src/components/ConfigurationV25.tsx
Normal file
529
frontend/src/components/ConfigurationV25.tsx
Normal file
@@ -0,0 +1,529 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
getEffortCalculationConfigV25,
|
||||
updateEffortCalculationConfigV25,
|
||||
getApplicationManagementHosting,
|
||||
getApplicationTypes,
|
||||
getBusinessImpactAnalyses,
|
||||
getGovernanceModels,
|
||||
type EffortCalculationConfigV25,
|
||||
type GovernanceModelConfigV25,
|
||||
type ApplicationTypeConfigV25,
|
||||
type BIALevelConfig,
|
||||
type FTERange,
|
||||
} from '../services/api';
|
||||
import type { ReferenceValue } from '../types';
|
||||
|
||||
export default function ConfigurationV25() {
|
||||
const [config, setConfig] = useState<EffortCalculationConfigV25 | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
// Reference data from Jira Assets
|
||||
const [hostingOptions, setHostingOptions] = useState<ReferenceValue[]>([]);
|
||||
const [applicationTypeOptions, setApplicationTypeOptions] = useState<ReferenceValue[]>([]);
|
||||
const [biaOptions, setBiaOptions] = useState<ReferenceValue[]>([]);
|
||||
const [governanceOptions, setGovernanceOptions] = useState<ReferenceValue[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
loadConfig();
|
||||
loadReferenceData();
|
||||
}, []);
|
||||
|
||||
const loadReferenceData = async () => {
|
||||
try {
|
||||
const [hosting, appTypes, bia, governance] = await Promise.all([
|
||||
getApplicationManagementHosting(),
|
||||
getApplicationTypes(),
|
||||
getBusinessImpactAnalyses(),
|
||||
getGovernanceModels(),
|
||||
]);
|
||||
setHostingOptions(hosting);
|
||||
setApplicationTypeOptions(appTypes);
|
||||
setBiaOptions(bia);
|
||||
setGovernanceOptions(governance);
|
||||
} catch (err) {
|
||||
console.error('Failed to load reference data:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const data = await getEffortCalculationConfigV25();
|
||||
setConfig(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load configuration');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!config) return;
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
await updateEffortCalculationConfigV25(config);
|
||||
setSuccess('Configuration v25 saved successfully!');
|
||||
setTimeout(() => setSuccess(null), 3000);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to save configuration');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateRegieModel = (code: string, updates: Partial<GovernanceModelConfigV25>) => {
|
||||
if (!config) return;
|
||||
setConfig({
|
||||
...config,
|
||||
regiemodellen: {
|
||||
...config.regiemodellen,
|
||||
[code]: {
|
||||
...config.regiemodellen[code],
|
||||
...updates,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const updateApplicationType = (regieModelCode: string, appType: string, updates: Partial<ApplicationTypeConfigV25>) => {
|
||||
if (!config) return;
|
||||
const regieModel = config.regiemodellen[regieModelCode];
|
||||
if (!regieModel) return;
|
||||
|
||||
setConfig({
|
||||
...config,
|
||||
regiemodellen: {
|
||||
...config.regiemodellen,
|
||||
[regieModelCode]: {
|
||||
...regieModel,
|
||||
applicationTypes: {
|
||||
...regieModel.applicationTypes,
|
||||
[appType]: {
|
||||
...regieModel.applicationTypes[appType],
|
||||
...updates,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-gray-500">Loading configuration v25...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
return (
|
||||
<div className="text-red-500">Failed to load configuration</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">FTE Configuration v25</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Configure the Required Effort Application Management calculation (Dienstencatalogus Applicatiebeheer v25)
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<button
|
||||
onClick={loadConfig}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
|
||||
>
|
||||
Reload
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save Configuration'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-4 bg-red-50 border border-red-200 rounded-md">
|
||||
<p className="text-sm text-red-800">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="p-4 bg-green-50 border border-green-200 rounded-md">
|
||||
<p className="text-sm text-green-800">{success}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Configuration Info</h2>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div><span className="font-medium">Version:</span> {config.metadata.version}</div>
|
||||
<div><span className="font-medium">Date:</span> {config.metadata.date}</div>
|
||||
<div className="col-span-2"><span className="font-medium">Formula:</span> {config.metadata.formula}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Validation Rules Summary */}
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Validation Rules</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-2">BIA vs Regiemodel Constraints</h3>
|
||||
<div className="grid grid-cols-3 gap-2 text-xs">
|
||||
{Object.entries(config.validationRules.biaRegieModelConstraints).map(([model, biaLevels]) => (
|
||||
<div key={model} className="bg-gray-50 p-2 rounded">
|
||||
<span className="font-medium">{model}:</span> {biaLevels.join(', ')}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-2">Platform Restrictions</h3>
|
||||
<div className="space-y-1 text-xs">
|
||||
{config.validationRules.platformRestrictions.map((r, i) => (
|
||||
<div key={i} className="bg-yellow-50 p-2 rounded">
|
||||
<span className="font-medium">{r.regiemodel} + {r.applicationType}:</span> {r.warning}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Regiemodellen */}
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Regiemodellen</h2>
|
||||
{Object.entries(config.regiemodellen)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([code, model]) => (
|
||||
<RegieModelEditor
|
||||
key={code}
|
||||
code={code}
|
||||
model={model}
|
||||
hostingOptions={hostingOptions}
|
||||
applicationTypeOptions={applicationTypeOptions}
|
||||
biaOptions={biaOptions}
|
||||
onUpdate={(updates) => updateRegieModel(code, updates)}
|
||||
onUpdateAppType={(appType, updates) => updateApplicationType(code, appType, updates)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface RegieModelEditorProps {
|
||||
code: string;
|
||||
model: GovernanceModelConfigV25;
|
||||
hostingOptions: ReferenceValue[];
|
||||
applicationTypeOptions: ReferenceValue[];
|
||||
biaOptions: ReferenceValue[];
|
||||
onUpdate: (updates: Partial<GovernanceModelConfigV25>) => void;
|
||||
onUpdateAppType: (appType: string, updates: Partial<ApplicationTypeConfigV25>) => void;
|
||||
}
|
||||
|
||||
function RegieModelEditor({
|
||||
code,
|
||||
model,
|
||||
hostingOptions,
|
||||
applicationTypeOptions,
|
||||
biaOptions,
|
||||
onUpdate,
|
||||
onUpdateAppType
|
||||
}: RegieModelEditorProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="bg-white shadow rounded-lg overflow-hidden">
|
||||
<div
|
||||
className="flex items-center justify-between p-4 bg-blue-50 cursor-pointer hover:bg-blue-100"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<div className="flex items-center space-x-4">
|
||||
<span className="text-lg">{expanded ? '▼' : '▶'}</span>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
Regiemodel {code}: {model.name}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600">{model.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right text-sm">
|
||||
<div className="font-medium">Default FTE: {model.defaultFte.min} - {model.defaultFte.max}</div>
|
||||
<div className="text-gray-500">Allowed BIA: {model.allowedBia.join(', ')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="p-4 space-y-4 border-t">
|
||||
{/* Default FTE Range */}
|
||||
<div className="flex items-center space-x-4">
|
||||
<label className="text-sm font-medium text-gray-700">Default FTE Range:</label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={model.defaultFte.min}
|
||||
onChange={(e) => onUpdate({
|
||||
defaultFte: { ...model.defaultFte, min: parseFloat(e.target.value) || 0 }
|
||||
})}
|
||||
className="w-20 px-2 py-1 text-sm border border-gray-300 rounded"
|
||||
/>
|
||||
<span>-</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={model.defaultFte.max}
|
||||
onChange={(e) => onUpdate({
|
||||
defaultFte: { ...model.defaultFte, max: parseFloat(e.target.value) || 0 }
|
||||
})}
|
||||
className="w-20 px-2 py-1 text-sm border border-gray-300 rounded"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Application Types */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium text-gray-700 border-b pb-2">Application Types</h4>
|
||||
{Object.entries(model.applicationTypes).map(([appType, appConfig]) => (
|
||||
<ApplicationTypeEditor
|
||||
key={appType}
|
||||
appType={appType}
|
||||
config={appConfig}
|
||||
hostingOptions={hostingOptions}
|
||||
biaOptions={biaOptions}
|
||||
onUpdate={(updates) => onUpdateAppType(appType, updates)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ApplicationTypeEditorProps {
|
||||
appType: string;
|
||||
config: ApplicationTypeConfigV25;
|
||||
hostingOptions: ReferenceValue[];
|
||||
biaOptions: ReferenceValue[];
|
||||
onUpdate: (updates: Partial<ApplicationTypeConfigV25>) => void;
|
||||
}
|
||||
|
||||
function ApplicationTypeEditor({ appType, config, hostingOptions, biaOptions, onUpdate }: ApplicationTypeEditorProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="border border-gray-200 rounded-lg">
|
||||
<div
|
||||
className="flex items-center justify-between p-3 bg-gray-50 cursor-pointer hover:bg-gray-100"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<span className="text-sm">{expanded ? '▼' : '▶'}</span>
|
||||
<span className="font-medium text-gray-800">{appType}</span>
|
||||
{config.fixedFte && <span className="text-xs bg-purple-100 text-purple-700 px-2 py-0.5 rounded">Vast</span>}
|
||||
{config.requiresManualAssessment && <span className="text-xs bg-orange-100 text-orange-700 px-2 py-0.5 rounded">Handmatig</span>}
|
||||
{config.notRecommended && <span className="text-xs bg-red-100 text-red-700 px-2 py-0.5 rounded">Niet aanbevolen</span>}
|
||||
</div>
|
||||
{config.defaultFte && (
|
||||
<span className="text-sm text-gray-600">
|
||||
Default: {config.defaultFte.min} - {config.defaultFte.max} FTE
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="p-3 space-y-3 border-t">
|
||||
{/* Flags */}
|
||||
<div className="flex items-center space-x-4 text-sm">
|
||||
<label className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.fixedFte || false}
|
||||
onChange={(e) => onUpdate({ fixedFte: e.target.checked })}
|
||||
className="rounded"
|
||||
/>
|
||||
<span>Fixed FTE</span>
|
||||
</label>
|
||||
<label className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.requiresManualAssessment || false}
|
||||
onChange={(e) => onUpdate({ requiresManualAssessment: e.target.checked })}
|
||||
className="rounded"
|
||||
/>
|
||||
<span>Requires Manual Assessment</span>
|
||||
</label>
|
||||
<label className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.notRecommended || false}
|
||||
onChange={(e) => onUpdate({ notRecommended: e.target.checked })}
|
||||
className="rounded"
|
||||
/>
|
||||
<span>Not Recommended</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Default FTE */}
|
||||
{config.defaultFte && (
|
||||
<div className="flex items-center space-x-4">
|
||||
<label className="text-sm font-medium text-gray-700">Default FTE:</label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={config.defaultFte.min}
|
||||
onChange={(e) => onUpdate({
|
||||
defaultFte: { ...config.defaultFte!, min: parseFloat(e.target.value) || 0 }
|
||||
})}
|
||||
className="w-16 px-2 py-1 text-sm border border-gray-300 rounded"
|
||||
/>
|
||||
<span>-</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={config.defaultFte.max}
|
||||
onChange={(e) => onUpdate({
|
||||
defaultFte: { ...config.defaultFte!, max: parseFloat(e.target.value) || 0 }
|
||||
})}
|
||||
className="w-16 px-2 py-1 text-sm border border-gray-300 rounded"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* BIA Levels */}
|
||||
<div className="space-y-2">
|
||||
<h5 className="text-sm font-medium text-gray-700">BIA Levels</h5>
|
||||
{Object.entries(config.biaLevels).map(([biaLevel, biaConfig]) => (
|
||||
<BIALevelEditor
|
||||
key={biaLevel}
|
||||
biaLevel={biaLevel}
|
||||
config={biaConfig}
|
||||
hostingOptions={hostingOptions}
|
||||
onUpdate={(updates) => {
|
||||
const newBiaLevels = { ...config.biaLevels };
|
||||
newBiaLevels[biaLevel] = { ...newBiaLevels[biaLevel], ...updates };
|
||||
onUpdate({ biaLevels: newBiaLevels });
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{config.note && (
|
||||
<div className="text-xs text-gray-500 italic bg-gray-50 p-2 rounded">
|
||||
Note: {config.note}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface BIALevelEditorProps {
|
||||
biaLevel: string;
|
||||
config: BIALevelConfig;
|
||||
hostingOptions: ReferenceValue[];
|
||||
onUpdate: (updates: Partial<BIALevelConfig>) => void;
|
||||
}
|
||||
|
||||
function BIALevelEditor({ biaLevel, config, hostingOptions, onUpdate }: BIALevelEditorProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="border border-gray-100 rounded bg-gray-50">
|
||||
<div
|
||||
className="flex items-center justify-between p-2 cursor-pointer hover:bg-gray-100"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-xs">{expanded ? '▼' : '▶'}</span>
|
||||
<span className="text-sm font-medium">
|
||||
{biaLevel === '_all' ? 'All BIA Levels' : `BIA ${biaLevel}`}
|
||||
</span>
|
||||
{config.description && (
|
||||
<span className="text-xs text-gray-500">- {config.description}</span>
|
||||
)}
|
||||
</div>
|
||||
{config.defaultFte && (
|
||||
<span className="text-xs text-gray-600">
|
||||
Default: {config.defaultFte.min} - {config.defaultFte.max}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="p-2 space-y-2 border-t bg-white">
|
||||
{/* Hosting Rules */}
|
||||
<div className="space-y-1">
|
||||
<h6 className="text-xs font-medium text-gray-600">Hosting Rules</h6>
|
||||
{Object.entries(config.hosting).map(([hostingKey, hostingRule]) => (
|
||||
<div key={hostingKey} className="flex items-center space-x-2 text-xs bg-blue-50 p-2 rounded">
|
||||
<span className="font-medium min-w-24">{hostingKey === '_all' ? 'All Hosting' : hostingKey}:</span>
|
||||
<span className="text-gray-600">
|
||||
[{hostingRule.hostingValues.join(', ')}]
|
||||
</span>
|
||||
<span className="ml-auto">
|
||||
FTE: {hostingRule.fte.min} - {hostingRule.fte.max}
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={hostingRule.fte.min}
|
||||
onChange={(e) => {
|
||||
const newHosting = { ...config.hosting };
|
||||
newHosting[hostingKey] = {
|
||||
...newHosting[hostingKey],
|
||||
fte: { ...newHosting[hostingKey].fte, min: parseFloat(e.target.value) || 0 }
|
||||
};
|
||||
onUpdate({ hosting: newHosting });
|
||||
}}
|
||||
className="w-14 px-1 py-0.5 text-xs border border-gray-300 rounded"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<span>-</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={hostingRule.fte.max}
|
||||
onChange={(e) => {
|
||||
const newHosting = { ...config.hosting };
|
||||
newHosting[hostingKey] = {
|
||||
...newHosting[hostingKey],
|
||||
fte: { ...newHosting[hostingKey].fte, max: parseFloat(e.target.value) || 0 }
|
||||
};
|
||||
onUpdate({ hosting: newHosting });
|
||||
}}
|
||||
className="w-14 px-1 py-0.5 text-xs border border-gray-300 rounded"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
199
frontend/src/components/CustomSelect.tsx
Normal file
199
frontend/src/components/CustomSelect.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { ReferenceValue } from '../types';
|
||||
|
||||
interface CustomSelectProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
options: ReferenceValue[];
|
||||
placeholder?: string;
|
||||
showSummary?: boolean;
|
||||
showRemarks?: boolean; // Show description + remarks concatenated
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Helper function to get display text for an option
|
||||
function getDisplayText(option: ReferenceValue, showSummary: boolean, showRemarks: boolean): string | null {
|
||||
if (showRemarks) {
|
||||
// Concatenate description and remarks with ". "
|
||||
const parts: string[] = [];
|
||||
if (option.description) parts.push(option.description);
|
||||
if (option.remarks) parts.push(option.remarks);
|
||||
return parts.length > 0 ? parts.join('. ') : null;
|
||||
}
|
||||
if (showSummary && option.summary) {
|
||||
return option.summary;
|
||||
}
|
||||
if (showSummary && !option.summary && option.description) {
|
||||
return option.description;
|
||||
}
|
||||
if (!showSummary && option.description) {
|
||||
return option.description;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function CustomSelect({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
placeholder = 'Selecteer...',
|
||||
showSummary = false,
|
||||
showRemarks = false,
|
||||
className = '',
|
||||
}: CustomSelectProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(-1);
|
||||
const selectRef = useRef<HTMLDivElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const selectedOption = options.find((opt) => opt.objectId === value);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
selectRef.current &&
|
||||
!selectRef.current.contains(event.target as Node) &&
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && dropdownRef.current) {
|
||||
const selectedElement = dropdownRef.current.querySelector('[data-selected="true"]');
|
||||
if (selectedElement) {
|
||||
selectedElement.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const handleSelect = (option: ReferenceValue) => {
|
||||
onChange(option.objectId);
|
||||
setIsOpen(false);
|
||||
setHighlightedIndex(-1);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
if (highlightedIndex >= 0 && highlightedIndex < options.length) {
|
||||
handleSelect(options[highlightedIndex]);
|
||||
} else {
|
||||
setIsOpen(!isOpen);
|
||||
}
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setIsOpen(true);
|
||||
setHighlightedIndex((prev) =>
|
||||
prev < options.length - 1 ? prev + 1 : prev
|
||||
);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : -1));
|
||||
} else if (e.key === 'Escape') {
|
||||
setIsOpen(false);
|
||||
setHighlightedIndex(-1);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative" ref={selectRef}>
|
||||
<div
|
||||
role="combobox"
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="listbox"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyDown}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={`w-full border border-gray-300 rounded-lg px-3 py-2 pr-10 bg-white cursor-pointer focus:ring-2 focus:ring-blue-500 focus:border-blue-500 ${className}`}
|
||||
>
|
||||
{selectedOption ? (
|
||||
<div className="pr-2">
|
||||
<div className="font-medium text-gray-900">{selectedOption.name}</div>
|
||||
{(() => {
|
||||
const displayText = getDisplayText(selectedOption, showSummary, showRemarks);
|
||||
return displayText ? (
|
||||
<div className="text-xs text-gray-500 mt-0.5 whitespace-normal break-words">
|
||||
{displayText}
|
||||
</div>
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-gray-500">{placeholder}</span>
|
||||
)}
|
||||
<svg
|
||||
className={`absolute right-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400 transition-transform ${
|
||||
isOpen ? '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>
|
||||
</div>
|
||||
|
||||
{isOpen && (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-lg shadow-lg max-h-60 overflow-auto"
|
||||
role="listbox"
|
||||
>
|
||||
{options.length === 0 ? (
|
||||
<div className="px-3 py-2 text-gray-500 text-sm">Geen opties beschikbaar</div>
|
||||
) : (
|
||||
options.map((option, index) => {
|
||||
const isSelected = option.objectId === value;
|
||||
const isHighlighted = index === highlightedIndex;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={option.objectId}
|
||||
data-selected={isSelected}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
onClick={() => handleSelect(option)}
|
||||
onMouseEnter={() => setHighlightedIndex(index)}
|
||||
className={`px-3 py-2 cursor-pointer transition-colors ${
|
||||
isSelected
|
||||
? 'bg-blue-50 text-blue-900'
|
||||
: isHighlighted
|
||||
? 'bg-gray-100'
|
||||
: 'hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium text-gray-900">{option.name}</div>
|
||||
{(() => {
|
||||
const displayText = getDisplayText(option, showSummary, showRemarks);
|
||||
return displayText ? (
|
||||
<div className="text-xs text-gray-600 mt-0.5 whitespace-normal break-words">
|
||||
{displayText}
|
||||
</div>
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
299
frontend/src/components/Dashboard.tsx
Normal file
299
frontend/src/components/Dashboard.tsx
Normal file
@@ -0,0 +1,299 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { getDashboardStats, getRecentClassifications } from '../services/api';
|
||||
import type { DashboardStats, ClassificationResult } from '../types';
|
||||
|
||||
// Extended type to include stale indicator from API
|
||||
interface DashboardStatsWithMeta extends DashboardStats {
|
||||
stale?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const [stats, setStats] = useState<DashboardStatsWithMeta | null>(null);
|
||||
const [recentClassifications, setRecentClassifications] = useState<ClassificationResult[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchData = useCallback(async (forceRefresh: boolean = false) => {
|
||||
if (forceRefresh) {
|
||||
setRefreshing(true);
|
||||
} else {
|
||||
setLoading(true);
|
||||
}
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const [statsData, recentData] = await Promise.all([
|
||||
getDashboardStats(forceRefresh),
|
||||
getRecentClassifications(10),
|
||||
]);
|
||||
setStats(statsData as DashboardStatsWithMeta);
|
||||
setRecentClassifications(recentData);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load dashboard');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData(false);
|
||||
}, [fetchData]);
|
||||
|
||||
const handleRefresh = () => {
|
||||
fetchData(true);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const progressPercentage = stats
|
||||
? Math.round((stats.classifiedCount / stats.totalApplications) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Page header */}
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900">Dashboard</h2>
|
||||
<p className="text-gray-600">
|
||||
Overzicht van de ZiRA classificatie voortgang
|
||||
{stats?.stale && (
|
||||
<span className="ml-2 text-amber-600 text-sm">
|
||||
(gecachte data - API timeout)
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing}
|
||||
className="btn btn-secondary flex items-center space-x-2"
|
||||
title="Ververs data"
|
||||
>
|
||||
<svg
|
||||
className={`w-4 h-4 ${refreshing ? 'animate-spin' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
<span>{refreshing ? 'Laden...' : 'Ververs'}</span>
|
||||
</button>
|
||||
<Link to="/applications" className="btn btn-primary">
|
||||
Start classificeren
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="card p-6">
|
||||
<div className="text-sm text-gray-500 mb-1">Totaal applicaties</div>
|
||||
<div className="text-3xl font-bold text-gray-900">
|
||||
{stats?.totalApplications || 0}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-6">
|
||||
<div className="text-sm text-gray-500 mb-1">Geclassificeerd</div>
|
||||
<div className="text-3xl font-bold text-green-600">
|
||||
{stats?.classifiedCount || 0}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-6">
|
||||
<div className="text-sm text-gray-500 mb-1">Nog te classificeren</div>
|
||||
<div className="text-3xl font-bold text-orange-600">
|
||||
{Math.max(0, stats?.unclassifiedCount || 0)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-6">
|
||||
<div className="text-sm text-gray-500 mb-1">Voortgang</div>
|
||||
<div className="text-3xl font-bold text-blue-600">
|
||||
{progressPercentage}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="card p-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">
|
||||
Classificatie voortgang
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm text-gray-600">
|
||||
<span>ApplicationFunction ingevuld</span>
|
||||
<span>
|
||||
{stats?.classifiedCount || 0} / {stats?.totalApplications || 0}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-4">
|
||||
<div
|
||||
className="bg-blue-600 h-4 rounded-full transition-all duration-500"
|
||||
style={{ width: `${progressPercentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Two column layout */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Status distribution */}
|
||||
<div className="card p-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">
|
||||
Verdeling per status
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{stats?.byStatus &&
|
||||
Object.entries(stats.byStatus)
|
||||
.sort((a, b) => {
|
||||
// Sort alphabetically, but put "Undefined" at the end
|
||||
if (a[0] === 'Undefined') return 1;
|
||||
if (b[0] === 'Undefined') return -1;
|
||||
return a[0].localeCompare(b[0], 'nl', { sensitivity: 'base' });
|
||||
})
|
||||
.map(([status, count]) => (
|
||||
<div key={status} className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">{status}</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-32 bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full"
|
||||
style={{
|
||||
width: `${(count / (stats?.totalApplications || 1)) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-900 w-8 text-right">
|
||||
{count}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Governance model distribution */}
|
||||
<div className="card p-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">
|
||||
Verdeling per regiemodel
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{stats?.byGovernanceModel &&
|
||||
Object.entries(stats.byGovernanceModel)
|
||||
.sort((a, b) => {
|
||||
// Sort alphabetically, but put "Niet ingesteld" at the end
|
||||
if (a[0] === 'Niet ingesteld') return 1;
|
||||
if (b[0] === 'Niet ingesteld') return -1;
|
||||
return a[0].localeCompare(b[0], 'nl', { sensitivity: 'base' });
|
||||
})
|
||||
.map(([model, count]) => (
|
||||
<div key={model} className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">{model}</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-32 bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-purple-600 h-2 rounded-full"
|
||||
style={{
|
||||
width: `${(count / (stats?.totalApplications || 1)) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-900 w-8 text-right">
|
||||
{count}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{(!stats?.byGovernanceModel ||
|
||||
Object.keys(stats.byGovernanceModel).length === 0) && (
|
||||
<p className="text-sm text-gray-500">Geen data beschikbaar</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent classifications */}
|
||||
<div className="card">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h3 className="text-lg font-medium text-gray-900">
|
||||
Recente classificaties
|
||||
</h3>
|
||||
</div>
|
||||
<div className="divide-y divide-gray-200">
|
||||
{recentClassifications.length === 0 ? (
|
||||
<div className="px-6 py-8 text-center text-gray-500">
|
||||
Nog geen classificaties uitgevoerd
|
||||
</div>
|
||||
) : (
|
||||
recentClassifications.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="px-6 py-4 flex items-center justify-between hover:bg-gray-50"
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">
|
||||
{item.applicationName}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{item.changes.applicationFunctions && item.changes.applicationFunctions.to.length > 0 && (
|
||||
<span>
|
||||
ApplicationFunctions: {item.changes.applicationFunctions.to.map((f) => f.name).join(', ')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<span
|
||||
className={`badge ${
|
||||
item.source === 'AI_ACCEPTED'
|
||||
? 'badge-green'
|
||||
: item.source === 'AI_MODIFIED'
|
||||
? 'badge-yellow'
|
||||
: 'badge-blue'
|
||||
}`}
|
||||
>
|
||||
{item.source === 'AI_ACCEPTED'
|
||||
? 'AI Geaccepteerd'
|
||||
: item.source === 'AI_MODIFIED'
|
||||
? 'AI Aangepast'
|
||||
: 'Handmatig'}
|
||||
</span>
|
||||
<span className="text-sm text-gray-500">
|
||||
{new Date(item.timestamp).toLocaleString('nl-NL')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
908
frontend/src/components/TeamDashboard.tsx
Normal file
908
frontend/src/components/TeamDashboard.tsx
Normal file
@@ -0,0 +1,908 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { getTeamDashboardData, getReferenceData } from '../services/api';
|
||||
import type { TeamDashboardData, TeamDashboardCluster, ApplicationStatus, ReferenceValue } from '../types';
|
||||
|
||||
const ALL_STATUSES: ApplicationStatus[] = [
|
||||
'In Production',
|
||||
'Implementation',
|
||||
'Proof of Concept',
|
||||
'End of support',
|
||||
'End of life',
|
||||
'Deprecated',
|
||||
'Shadow IT',
|
||||
'Closed',
|
||||
'Undefined',
|
||||
];
|
||||
|
||||
type SortOption = 'alphabetical' | 'fte-descending';
|
||||
|
||||
// Color scheme for governance models - matches exact names from Jira Assets
|
||||
const GOVERNANCE_MODEL_COLORS: Record<string, { bg: string; text: string; letter: string }> = {
|
||||
'Regiemodel A': { bg: '#20556B', text: '#FFFFFF', letter: 'A' },
|
||||
'Regiemodel B': { bg: '#286B86', text: '#FFFFFF', letter: 'B' },
|
||||
'Regiemodel B+': { bg: '#286B86', text: '#FFFFFF', letter: 'B+' },
|
||||
'Regiemodel C': { bg: '#81CBF2', text: '#20556B', letter: 'C' },
|
||||
'Regiemodel D': { bg: '#F5A733', text: '#FFFFFF', letter: 'D' },
|
||||
'Regiemodel E': { bg: '#E95053', text: '#FFFFFF', letter: 'E' },
|
||||
'Niet ingesteld': { bg: '#EEEEEE', text: '#AAAAAA', letter: '?' },
|
||||
};
|
||||
|
||||
// Get governance model colors and letter - with fallback for unknown models
|
||||
const getGovernanceModelStyle = (governanceModelName: string | null | undefined) => {
|
||||
const name = governanceModelName || 'Niet ingesteld';
|
||||
|
||||
// First try exact match
|
||||
if (GOVERNANCE_MODEL_COLORS[name]) {
|
||||
return GOVERNANCE_MODEL_COLORS[name];
|
||||
}
|
||||
|
||||
// Try to match by pattern (e.g., "Regiemodel X" -> letter X)
|
||||
const match = name.match(/Regiemodel\s+(.+)/i);
|
||||
if (match) {
|
||||
const letter = match[1];
|
||||
// Return a color based on the letter
|
||||
if (letter === 'A') return { bg: '#20556B', text: '#FFFFFF', letter: 'A' };
|
||||
if (letter === 'B') return { bg: '#286B86', text: '#FFFFFF', letter: 'B' };
|
||||
if (letter === 'B+') return { bg: '#286B86', text: '#FFFFFF', letter: 'B+' };
|
||||
if (letter === 'C') return { bg: '#81CBF2', text: '#20556B', letter: 'C' };
|
||||
if (letter === 'D') return { bg: '#F5A733', text: '#FFFFFF', letter: 'D' };
|
||||
if (letter === 'E') return { bg: '#E95053', text: '#FFFFFF', letter: 'E' };
|
||||
return { bg: '#6B7280', text: '#FFFFFF', letter };
|
||||
}
|
||||
|
||||
return { bg: '#6B7280', text: '#FFFFFF', letter: '?' };
|
||||
};
|
||||
|
||||
export default function TeamDashboard() {
|
||||
const [data, setData] = useState<TeamDashboardData | null>(null);
|
||||
const [initialLoading, setInitialLoading] = useState(true); // Only for first load
|
||||
const [dataLoading, setDataLoading] = useState(false); // For filter changes
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [expandedClusters, setExpandedClusters] = useState<Set<string>>(new Set()); // Start with all clusters collapsed
|
||||
const [expandedPlatforms, setExpandedPlatforms] = useState<Set<string>>(new Set()); // Track expanded platforms
|
||||
// Status filter: excludedStatuses contains statuses that are NOT shown
|
||||
const [excludedStatuses, setExcludedStatuses] = useState<ApplicationStatus[]>(['Closed', 'Deprecated']); // Default: exclude Closed and Deprecated
|
||||
const [sortOption, setSortOption] = useState<SortOption>('fte-descending');
|
||||
const [statusDropdownOpen, setStatusDropdownOpen] = useState(false);
|
||||
const [governanceModels, setGovernanceModels] = useState<ReferenceValue[]>([]);
|
||||
const [hoveredGovModel, setHoveredGovModel] = useState<string | null>(null);
|
||||
|
||||
// Fetch governance models on mount
|
||||
useEffect(() => {
|
||||
async function fetchGovernanceModels() {
|
||||
try {
|
||||
const refData = await getReferenceData();
|
||||
setGovernanceModels(refData.governanceModels);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch governance models:', err);
|
||||
}
|
||||
}
|
||||
fetchGovernanceModels();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
try {
|
||||
// Only show full page loading on initial load
|
||||
const isInitialLoad = data === null;
|
||||
if (isInitialLoad) {
|
||||
setInitialLoading(true);
|
||||
} else {
|
||||
setDataLoading(true);
|
||||
}
|
||||
const dashboardData = await getTeamDashboardData(excludedStatuses);
|
||||
setData(dashboardData);
|
||||
// Keep clusters collapsed by default (expandedClusters remains empty)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load team dashboard');
|
||||
} finally {
|
||||
setInitialLoading(false);
|
||||
setDataLoading(false);
|
||||
}
|
||||
}
|
||||
fetchData();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [excludedStatuses]);
|
||||
|
||||
// Close status dropdown when pressing Escape
|
||||
useEffect(() => {
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && statusDropdownOpen) {
|
||||
setStatusDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (statusDropdownOpen) {
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
};
|
||||
}, [statusDropdownOpen]);
|
||||
|
||||
const toggleCluster = (clusterId: string, event?: React.MouseEvent) => {
|
||||
// Prevent scroll jump by storing and restoring scroll position
|
||||
const scrollY = window.scrollY;
|
||||
|
||||
setExpandedClusters(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(clusterId)) {
|
||||
newSet.delete(clusterId);
|
||||
} else {
|
||||
newSet.add(clusterId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
|
||||
// Use requestAnimationFrame to restore scroll position after state update
|
||||
requestAnimationFrame(() => {
|
||||
window.scrollTo(0, scrollY);
|
||||
});
|
||||
};
|
||||
|
||||
const togglePlatform = (platformId: string, e?: React.MouseEvent) => {
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
setExpandedPlatforms(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(platformId)) {
|
||||
newSet.delete(platformId);
|
||||
} else {
|
||||
newSet.add(platformId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleStatus = (status: ApplicationStatus) => {
|
||||
setExcludedStatuses(prev => {
|
||||
if (prev.includes(status)) {
|
||||
// Remove from excluded (show it)
|
||||
return prev.filter(s => s !== status);
|
||||
} else {
|
||||
// Add to excluded (hide it)
|
||||
return [...prev, status];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Only show full page loading on initial load
|
||||
if (initialLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const hasNoApplications = data ? (
|
||||
data.clusters.length === 0 &&
|
||||
data.unassigned.applications.length === 0 &&
|
||||
data.unassigned.platforms.length === 0
|
||||
) : true;
|
||||
|
||||
const ClusterBlock = ({ clusterData, isUnassigned = false }: { clusterData: TeamDashboardCluster; isUnassigned?: boolean }) => {
|
||||
const clusterId = clusterData.cluster?.objectId || 'unassigned';
|
||||
const isExpanded = expandedClusters.has(clusterId);
|
||||
const clusterName = isUnassigned ? 'Nog niet toegekend' : (clusterData.cluster?.name || 'Onbekend');
|
||||
|
||||
// Helper function to get effective FTE for an application
|
||||
const getEffectiveFTE = (app: { overrideFTE?: number | null; requiredEffortApplicationManagement?: number | null }) =>
|
||||
app.overrideFTE !== null && app.overrideFTE !== undefined ? app.overrideFTE : (app.requiredEffortApplicationManagement || 0);
|
||||
|
||||
// Use pre-calculated min/max from backend (sum of all min/max FTE values)
|
||||
const minFTE = clusterData.minEffort ?? 0;
|
||||
const maxFTE = clusterData.maxEffort ?? 0;
|
||||
|
||||
// Calculate application type distribution
|
||||
const byApplicationType: Record<string, number> = {};
|
||||
clusterData.applications.forEach(app => {
|
||||
const appType = app.applicationType?.name || 'Niet ingesteld';
|
||||
byApplicationType[appType] = (byApplicationType[appType] || 0) + 1;
|
||||
});
|
||||
clusterData.platforms.forEach(platformWithWorkloads => {
|
||||
const platformType = platformWithWorkloads.platform.applicationType?.name || 'Niet ingesteld';
|
||||
byApplicationType[platformType] = (byApplicationType[platformType] || 0) + 1;
|
||||
platformWithWorkloads.workloads.forEach(workload => {
|
||||
const workloadType = workload.applicationType?.name || 'Niet ingesteld';
|
||||
byApplicationType[workloadType] = (byApplicationType[workloadType] || 0) + 1;
|
||||
});
|
||||
});
|
||||
|
||||
// Sort applications based on selected sort option
|
||||
const sortedApplications = [...clusterData.applications].sort((a, b) => {
|
||||
if (sortOption === 'alphabetical') {
|
||||
return a.name.localeCompare(b.name, 'nl', { sensitivity: 'base' });
|
||||
} else {
|
||||
// Sort by FTE descending (use override if present, otherwise calculated)
|
||||
const aFTE = getEffectiveFTE(a);
|
||||
const bFTE = getEffectiveFTE(b);
|
||||
return bFTE - aFTE;
|
||||
}
|
||||
});
|
||||
|
||||
// Sort platforms based on selected sort option
|
||||
const sortedPlatforms = [...clusterData.platforms].sort((a, b) => {
|
||||
if (sortOption === 'alphabetical') {
|
||||
return a.platform.name.localeCompare(b.platform.name, 'nl', { sensitivity: 'base' });
|
||||
} else {
|
||||
// Sort by total FTE descending
|
||||
return b.totalEffort - a.totalEffort;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="bg-gray-50 rounded-lg shadow-sm border border-gray-200 mb-4" style={{ overflow: 'visible' }}>
|
||||
<button
|
||||
onClick={() => toggleCluster(clusterId)}
|
||||
className="w-full px-6 py-4 hover:bg-gray-50 transition-colors text-left"
|
||||
style={{ overflow: 'visible' }}
|
||||
>
|
||||
{/* First row: Cluster name and expand icon */}
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex-shrink-0">
|
||||
{isExpanded ? (
|
||||
<svg className="w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 flex-1">{clusterName}</h3>
|
||||
</div>
|
||||
|
||||
{/* Second row: KPIs - all horizontally aligned */}
|
||||
<div className="mt-3 ml-9 flex flex-wrap items-stretch gap-4">
|
||||
{/* FTE: Total and Min-Max range - most important KPI first - with highlight */}
|
||||
<div className="bg-gradient-to-br from-emerald-50 to-teal-50 border border-emerald-200 rounded-xl px-4 py-3 shadow-sm flex flex-col justify-center w-[180px] flex-shrink-0">
|
||||
<div className="text-xs text-emerald-700 font-semibold flex items-center gap-1.5">
|
||||
<svg className="w-4 h-4" 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>
|
||||
FTE Totaal
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-emerald-800">{clusterData.totalEffort.toFixed(2)}</div>
|
||||
<div className="text-[10px] text-emerald-600 font-medium mt-1">
|
||||
Bandbreedte:
|
||||
</div>
|
||||
<div className="text-xs text-emerald-600 font-medium">
|
||||
{minFTE.toFixed(2)} - {maxFTE.toFixed(2)} FTE
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Applicatie count with type distribution */}
|
||||
<div className="flex flex-col justify-center">
|
||||
<div className="text-xs text-gray-500 font-semibold flex items-center gap-1.5">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
Applicaties
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-gray-900">{clusterData.applicationCount}</div>
|
||||
{Object.keys(byApplicationType).length > 0 && (
|
||||
<div className="text-xs text-gray-500 mt-1 grid grid-cols-2 gap-x-4 gap-y-0.5">
|
||||
{Object.entries(byApplicationType)
|
||||
.sort((a, b) => {
|
||||
// Sort "Niet ingesteld" to the end
|
||||
if (a[0] === 'Niet ingesteld') return 1;
|
||||
if (b[0] === 'Niet ingesteld') return -1;
|
||||
return a[0].localeCompare(b[0], 'nl', { sensitivity: 'base' });
|
||||
})
|
||||
.map(([type, count]) => {
|
||||
// Color coding for application types
|
||||
const typeColors: Record<string, string> = {
|
||||
'Applicatie': 'bg-blue-400',
|
||||
'Platform': 'bg-purple-400',
|
||||
'Workload': 'bg-orange-400',
|
||||
'Connected Device': 'bg-cyan-400',
|
||||
'Niet ingesteld': 'bg-gray-300',
|
||||
};
|
||||
const dotColor = typeColors[type] || 'bg-gray-400';
|
||||
|
||||
return (
|
||||
<div key={type} className="flex items-center gap-1.5">
|
||||
<span className={`w-2 h-2 rounded-full ${dotColor} flex-shrink-0`}></span>
|
||||
<span className="truncate">{type}: <span className="font-medium text-gray-700">{count}</span></span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Governance Model Distribution - right aligned */}
|
||||
<div className="flex-1 flex flex-col justify-center" style={{ position: 'relative', zIndex: 10, overflow: 'visible' }}>
|
||||
<div className="text-xs font-semibold text-gray-500 text-right mb-1.5 flex items-center justify-end gap-1">
|
||||
Verdeling per regiemodel
|
||||
<span className="text-gray-400 text-[10px]" title="Hover voor details">ⓘ</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 justify-end" style={{ overflow: 'visible' }}>
|
||||
{/* Show all governance models from Jira Assets + "Niet ingesteld" */}
|
||||
{(() => {
|
||||
// Get all governance models, sort alphabetically, add "Niet ingesteld" at the end
|
||||
const allModels = [
|
||||
...governanceModels
|
||||
.map(g => g.name)
|
||||
.sort((a, b) => a.localeCompare(b, 'nl', { sensitivity: 'base' })),
|
||||
'Niet ingesteld'
|
||||
];
|
||||
|
||||
// Color schemes based on the model name/key
|
||||
const getColorScheme = (name: string): { bg: string; text: string } => {
|
||||
if (name.includes('Regiemodel A')) return { bg: '#20556B', text: '#FFFFFF' };
|
||||
if (name.includes('Regiemodel B+') || name.includes('B+')) return { bg: '#286B86', text: '#FFFFFF' };
|
||||
if (name.includes('Regiemodel B')) return { bg: '#286B86', text: '#FFFFFF' };
|
||||
if (name.includes('Regiemodel C')) return { bg: '#81CBF2', text: '#20556B' };
|
||||
if (name.includes('Regiemodel D')) return { bg: '#F5A733', text: '#FFFFFF' };
|
||||
if (name.includes('Regiemodel E')) return { bg: '#E95053', text: '#FFFFFF' };
|
||||
if (name === 'Niet ingesteld') return { bg: '#E5E7EB', text: '#9CA3AF' };
|
||||
return { bg: '#6B7280', text: '#FFFFFF' }; // Default gray
|
||||
};
|
||||
|
||||
// Get short label from model name
|
||||
const getShortLabel = (name: string): string => {
|
||||
if (name === 'Niet ingesteld') return '?';
|
||||
// Extract letter(s) after "Regiemodel " or use first char
|
||||
const match = name.match(/Regiemodel\s+(.+)/i);
|
||||
return match ? match[1] : name.charAt(0);
|
||||
};
|
||||
|
||||
return allModels;
|
||||
})()
|
||||
.map((govModel) => {
|
||||
const count = clusterData.byGovernanceModel[govModel] || 0;
|
||||
const colors = (() => {
|
||||
if (govModel.includes('Regiemodel A')) return { bg: '#20556B', text: '#FFFFFF' };
|
||||
if (govModel.includes('Regiemodel B+') || govModel.includes('B+')) return { bg: '#286B86', text: '#FFFFFF' };
|
||||
if (govModel.includes('Regiemodel B')) return { bg: '#286B86', text: '#FFFFFF' };
|
||||
if (govModel.includes('Regiemodel C')) return { bg: '#81CBF2', text: '#20556B' };
|
||||
if (govModel.includes('Regiemodel D')) return { bg: '#F5A733', text: '#FFFFFF' };
|
||||
if (govModel.includes('Regiemodel E')) return { bg: '#E95053', text: '#FFFFFF' };
|
||||
if (govModel === 'Niet ingesteld') return { bg: '#E5E7EB', text: '#9CA3AF' };
|
||||
return { bg: '#6B7280', text: '#FFFFFF' }; // Default gray
|
||||
})();
|
||||
// Short label: extract letter(s) after "Regiemodel " or use "?"
|
||||
const shortLabel = govModel === 'Niet ingesteld'
|
||||
? '?'
|
||||
: (govModel.match(/Regiemodel\s+(.+)/i)?.[1] || govModel.charAt(0));
|
||||
// Find governance model details from fetched data
|
||||
const govModelData = governanceModels.find(g => g.name === govModel);
|
||||
const hoverKey = `${clusterId}-${govModel}`;
|
||||
const isHovered = hoveredGovModel === hoverKey;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={govModel}
|
||||
className={`rounded-xl py-2 shadow-sm hover:shadow-lg transition-all duration-200 w-[48px] text-center cursor-pointer ${count === 0 ? 'opacity-50 hover:opacity-70' : ''}`}
|
||||
style={{
|
||||
backgroundColor: colors.bg,
|
||||
color: colors.text,
|
||||
position: isHovered ? 'relative' : 'static',
|
||||
zIndex: isHovered ? 9999 : 'auto'
|
||||
}}
|
||||
onMouseEnter={() => setHoveredGovModel(hoverKey)}
|
||||
onMouseLeave={() => setHoveredGovModel(null)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="text-[10px] font-bold uppercase tracking-wider" style={{ opacity: 0.9 }}>
|
||||
{shortLabel}
|
||||
</div>
|
||||
<div className="text-xl font-bold leading-tight">
|
||||
{count}
|
||||
</div>
|
||||
|
||||
{/* Hover popup */}
|
||||
{isHovered && govModel !== 'Niet ingesteld' && (
|
||||
<div
|
||||
className="absolute right-0 top-full mt-3 w-80 rounded-xl shadow-2xl border border-gray-200 p-4 text-left"
|
||||
style={{
|
||||
pointerEvents: 'auto',
|
||||
zIndex: 99999,
|
||||
backgroundColor: '#ffffff',
|
||||
opacity: 1
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Arrow pointer */}
|
||||
<div
|
||||
className="absolute -top-2 right-5 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))' }}
|
||||
/>
|
||||
|
||||
{/* Header: Summary (Description) */}
|
||||
<div className="text-sm font-bold text-gray-900 mb-2">
|
||||
{govModelData?.summary || govModel}
|
||||
{govModelData?.description && (
|
||||
<span className="font-normal text-gray-500"> ({govModelData.description})</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Remarks */}
|
||||
{govModelData?.remarks && (
|
||||
<div className="text-xs text-gray-600 mb-3 whitespace-pre-wrap leading-relaxed">
|
||||
{govModelData.remarks}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Application section */}
|
||||
{govModelData?.application && (
|
||||
<div className="border-t border-gray-100 pt-3 mt-3">
|
||||
<div className="text-xs font-bold text-gray-700 mb-1.5 uppercase tracking-wide">
|
||||
Toepassing
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 whitespace-pre-wrap leading-relaxed">
|
||||
{govModelData.application}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fallback message if no data */}
|
||||
{!govModelData && (
|
||||
<div className="text-xs text-gray-400 italic">
|
||||
Geen aanvullende informatie beschikbaar
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="border-t border-gray-200 px-6 py-4">
|
||||
{clusterData.applications.length === 0 && clusterData.platforms.length === 0 ? (
|
||||
<p className="text-sm text-gray-500">Geen applicaties in dit cluster</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{/* Platforms with Workloads - shown first */}
|
||||
{sortedPlatforms.map((platformWithWorkloads) => {
|
||||
const platformId = platformWithWorkloads.platform.id;
|
||||
const isPlatformExpanded = expandedPlatforms.has(platformId);
|
||||
const hasWorkloads = platformWithWorkloads.workloads.length > 0;
|
||||
const platformGovStyle = getGovernanceModelStyle(platformWithWorkloads.platform.governanceModel?.name);
|
||||
const platform = platformWithWorkloads.platform;
|
||||
const platformMinFTE = platform.overrideFTE !== null && platform.overrideFTE !== undefined
|
||||
? platform.overrideFTE
|
||||
: (platform.minFTE ?? platform.requiredEffortApplicationManagement ?? 0);
|
||||
const platformMaxFTE = platform.overrideFTE !== null && platform.overrideFTE !== undefined
|
||||
? platform.overrideFTE
|
||||
: (platform.maxFTE ?? platform.requiredEffortApplicationManagement ?? 0);
|
||||
// Calculate total min/max including workloads
|
||||
const totalMinFTE = platformMinFTE + platformWithWorkloads.workloads.reduce((sum, w) => {
|
||||
return sum + (w.overrideFTE ?? w.minFTE ?? w.requiredEffortApplicationManagement ?? 0);
|
||||
}, 0);
|
||||
const totalMaxFTE = platformMaxFTE + platformWithWorkloads.workloads.reduce((sum, w) => {
|
||||
return sum + (w.overrideFTE ?? w.maxFTE ?? w.requiredEffortApplicationManagement ?? 0);
|
||||
}, 0);
|
||||
|
||||
return (
|
||||
<div key={platformId} className="border border-blue-200 rounded-lg bg-blue-50 overflow-hidden flex">
|
||||
{/* Governance Model indicator */}
|
||||
<div
|
||||
className="w-10 flex-shrink-0 flex items-center justify-center font-bold text-sm"
|
||||
style={{ backgroundColor: platformGovStyle.bg, color: platformGovStyle.text }}
|
||||
title={platform.governanceModel?.name || 'Niet ingesteld'}
|
||||
>
|
||||
{platformGovStyle.letter}
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
{/* Platform header */}
|
||||
<div className="flex items-center">
|
||||
{hasWorkloads && (
|
||||
<button
|
||||
onClick={(e) => togglePlatform(platformId, e)}
|
||||
className="p-2 hover:bg-blue-100 transition-colors flex-shrink-0"
|
||||
title={isPlatformExpanded ? 'Inklappen' : 'Uitklappen'}
|
||||
>
|
||||
<svg
|
||||
className={`w-5 h-5 text-blue-700 transition-transform ${isPlatformExpanded ? 'transform rotate-90' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<Link
|
||||
to={`/applications/${platformId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`flex-1 p-3 hover:bg-blue-100 transition-colors ${!hasWorkloads ? 'rounded-r-lg' : ''}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-2 flex-wrap gap-y-1">
|
||||
<div className="font-medium text-gray-900">{platformWithWorkloads.platform.name}</div>
|
||||
<span className="text-xs font-semibold text-blue-700 bg-blue-200 px-2 py-0.5 rounded">
|
||||
Platform
|
||||
</span>
|
||||
{platform.applicationManagementHosting?.name && (
|
||||
<span className="text-xs px-2 py-0.5 bg-blue-100 text-blue-700 rounded-full">
|
||||
{platform.applicationManagementHosting.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">{platformWithWorkloads.platform.key}</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
{(() => {
|
||||
const platformHasOverride = platform.overrideFTE !== null && platform.overrideFTE !== undefined;
|
||||
const platformCalculated = platform.requiredEffortApplicationManagement || 0;
|
||||
const workloadsCalculated = platformWithWorkloads.workloads.reduce((sum, w) =>
|
||||
sum + (w.requiredEffortApplicationManagement || 0), 0
|
||||
);
|
||||
const totalCalculated = platformCalculated + workloadsCalculated;
|
||||
const hasAnyOverride = platformHasOverride || platformWithWorkloads.workloads.some(w =>
|
||||
w.overrideFTE !== null && w.overrideFTE !== undefined
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{platformWithWorkloads.totalEffort.toFixed(2)} FTE
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
{totalMinFTE.toFixed(2)} - {totalMaxFTE.toFixed(2)}
|
||||
</div>
|
||||
{hasAnyOverride && (
|
||||
<div className="text-xs text-gray-400 mt-1">
|
||||
(berekend: {totalCalculated.toFixed(2)})
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
Platform: {platformWithWorkloads.platformEffort.toFixed(2)} FTE
|
||||
{platformHasOverride && platformCalculated !== null && (
|
||||
<span className="text-gray-400"> (berekend: {platformCalculated.toFixed(2)})</span>
|
||||
)}
|
||||
{hasWorkloads && (
|
||||
<> + Workloads: {platformWithWorkloads.workloadsEffort.toFixed(2)} FTE</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Workloads list */}
|
||||
{hasWorkloads && isPlatformExpanded && (
|
||||
<div className="border-t border-blue-200 bg-white rounded-br-lg">
|
||||
<div className="px-3 py-2 bg-blue-100 border-b border-blue-200">
|
||||
<div className="text-xs font-medium text-blue-700">
|
||||
Workloads ({platformWithWorkloads.workloads.length})
|
||||
</div>
|
||||
</div>
|
||||
<div className="divide-y divide-gray-200">
|
||||
{[...platformWithWorkloads.workloads]
|
||||
.sort((a, b) => {
|
||||
if (sortOption === 'alphabetical') {
|
||||
return a.name.localeCompare(b.name, 'nl', { sensitivity: 'base' });
|
||||
} else {
|
||||
// Sort by FTE descending (use override if present, otherwise calculated)
|
||||
const wlEffectiveFTE = (wl: typeof a) => wl.overrideFTE !== null && wl.overrideFTE !== undefined ? wl.overrideFTE : (wl.requiredEffortApplicationManagement || 0);
|
||||
const aFTE = wlEffectiveFTE(a);
|
||||
const bFTE = wlEffectiveFTE(b);
|
||||
return bFTE - aFTE;
|
||||
}
|
||||
})
|
||||
.map((workload) => {
|
||||
const workloadGovStyle = getGovernanceModelStyle(workload.governanceModel?.name);
|
||||
const workloadType = workload.applicationType?.name || 'Workload';
|
||||
const workloadHosting = workload.applicationManagementHosting?.name;
|
||||
const workloadEffectiveFTE = workload.overrideFTE !== null && workload.overrideFTE !== undefined
|
||||
? workload.overrideFTE
|
||||
: workload.requiredEffortApplicationManagement;
|
||||
const workloadMinFTE = workload.overrideFTE ?? workload.minFTE ?? workload.requiredEffortApplicationManagement ?? 0;
|
||||
const workloadMaxFTE = workload.overrideFTE ?? workload.maxFTE ?? workload.requiredEffortApplicationManagement ?? 0;
|
||||
|
||||
return (
|
||||
<div key={workload.id} className="flex items-stretch">
|
||||
{/* Governance Model indicator for workload */}
|
||||
<div
|
||||
className="w-8 flex-shrink-0 flex items-center justify-center font-bold text-xs"
|
||||
style={{ backgroundColor: workloadGovStyle.bg, color: workloadGovStyle.text, opacity: 0.7 }}
|
||||
title={workload.governanceModel?.name || 'Niet ingesteld'}
|
||||
>
|
||||
{workloadGovStyle.letter}
|
||||
</div>
|
||||
<Link
|
||||
to={`/applications/${workload.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex-1 px-4 py-2 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium text-gray-700">{workload.name}</span>
|
||||
<span className="text-[10px] px-1.5 py-0.5 bg-gray-100 text-gray-500 rounded">
|
||||
{workloadType}
|
||||
</span>
|
||||
{workloadHosting && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 bg-blue-50 text-blue-600 rounded">
|
||||
{workloadHosting}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">{workload.key}</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
{workloadEffectiveFTE !== null && workloadEffectiveFTE !== undefined ? (
|
||||
<div>
|
||||
<div className="text-xs font-medium text-gray-600">
|
||||
{workloadEffectiveFTE.toFixed(2)} FTE
|
||||
</div>
|
||||
<div className="text-[10px] text-gray-400">
|
||||
{workloadMinFTE.toFixed(2)} - {workloadMaxFTE.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-gray-400">Niet berekend</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Regular applications - shown after platforms */}
|
||||
{sortedApplications.map((app) => {
|
||||
const govStyle = getGovernanceModelStyle(app.governanceModel?.name);
|
||||
const appType = app.applicationType?.name || 'Niet ingesteld';
|
||||
const appHosting = app.applicationManagementHosting?.name;
|
||||
const effectiveFTE = app.overrideFTE !== null && app.overrideFTE !== undefined
|
||||
? app.overrideFTE
|
||||
: app.requiredEffortApplicationManagement;
|
||||
const appMinFTE = app.overrideFTE !== null && app.overrideFTE !== undefined
|
||||
? app.overrideFTE
|
||||
: (app.minFTE ?? app.requiredEffortApplicationManagement ?? 0);
|
||||
const appMaxFTE = app.overrideFTE !== null && app.overrideFTE !== undefined
|
||||
? app.overrideFTE
|
||||
: (app.maxFTE ?? app.requiredEffortApplicationManagement ?? 0);
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={app.id}
|
||||
to={`/applications/${app.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-stretch bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors overflow-hidden"
|
||||
>
|
||||
{/* Governance Model indicator */}
|
||||
<div
|
||||
className="w-10 flex-shrink-0 flex items-center justify-center font-bold text-sm"
|
||||
style={{ backgroundColor: govStyle.bg, color: govStyle.text }}
|
||||
title={app.governanceModel?.name || 'Niet ingesteld'}
|
||||
>
|
||||
{govStyle.letter}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 p-3 flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium text-gray-900">{app.name}</span>
|
||||
<span className="text-xs px-2 py-0.5 bg-gray-200 text-gray-600 rounded-full">
|
||||
{appType}
|
||||
</span>
|
||||
{appHosting && (
|
||||
<span className="text-xs px-2 py-0.5 bg-blue-100 text-blue-700 rounded-full">
|
||||
{appHosting}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">{app.key}</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
{effectiveFTE !== null && effectiveFTE !== undefined ? (
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{effectiveFTE.toFixed(2)} FTE
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
{appMinFTE.toFixed(2)} - {appMaxFTE.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-gray-400">Niet berekend</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Team-indeling</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Overzicht van applicaties gegroepeerd per Application Cluster
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Compact Filter Bar */}
|
||||
<div className="mb-6 bg-gray-50 rounded-lg shadow-sm border border-gray-200 p-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{/* Sort Option */}
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs font-medium text-gray-600 whitespace-nowrap">
|
||||
Sorteer:
|
||||
</label>
|
||||
<select
|
||||
value={sortOption}
|
||||
onChange={(e) => setSortOption(e.target.value as SortOption)}
|
||||
className="px-2.5 py-1.5 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500 text-sm bg-white min-w-[140px]"
|
||||
>
|
||||
<option value="alphabetical">Alfabetisch</option>
|
||||
<option value="fte-descending">FTE (aflopend)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Status Filter Dropdown */}
|
||||
<div className="flex items-center gap-2 relative">
|
||||
<label className="text-xs font-medium text-gray-600 whitespace-nowrap">
|
||||
Status:
|
||||
</label>
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStatusDropdownOpen(!statusDropdownOpen)}
|
||||
className="px-2.5 py-1.5 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500 text-sm bg-white min-w-[180px] text-left flex items-center justify-between gap-2 hover:bg-gray-50"
|
||||
>
|
||||
<span className="text-gray-700">
|
||||
{excludedStatuses.length === 0
|
||||
? 'Alle statussen'
|
||||
: excludedStatuses.length === 1
|
||||
? `${ALL_STATUSES.length - 1} van ${ALL_STATUSES.length}`
|
||||
: `${ALL_STATUSES.length - excludedStatuses.length} van ${ALL_STATUSES.length}`}
|
||||
</span>
|
||||
<svg
|
||||
className={`w-4 h-4 text-gray-400 transition-transform ${statusDropdownOpen ? '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>
|
||||
|
||||
{statusDropdownOpen && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-10"
|
||||
onClick={() => setStatusDropdownOpen(false)}
|
||||
/>
|
||||
<div className="absolute z-20 mt-1 w-64 bg-white border border-gray-300 rounded-md shadow-lg max-h-80 overflow-auto">
|
||||
<div className="p-2 border-b border-gray-200 flex items-center justify-between">
|
||||
<span className="text-xs font-semibold text-gray-700">Selecteer statussen</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setExcludedStatuses(['Closed', 'Deprecated']);
|
||||
setStatusDropdownOpen(false);
|
||||
}}
|
||||
className="text-xs text-blue-600 hover:text-blue-700 font-medium"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-2 space-y-1">
|
||||
{ALL_STATUSES.map((status) => {
|
||||
const isExcluded = excludedStatuses.includes(status);
|
||||
return (
|
||||
<label
|
||||
key={status}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex items-center space-x-2 cursor-pointer p-2 rounded hover:bg-gray-50"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!isExcluded}
|
||||
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 className="p-2 border-t border-gray-200 bg-gray-50">
|
||||
<p className="text-xs text-gray-500">
|
||||
Uitgevinkte statussen worden verborgen
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<div className="mb-6 bg-red-50 border border-red-200 rounded-lg p-4 text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading indicator for data updates */}
|
||||
{dataLoading && (
|
||||
<div className="mb-6 flex items-center justify-center py-4">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600" />
|
||||
<span>Resultaten bijwerken...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Clusters */}
|
||||
{!dataLoading && data && data.clusters.length > 0 && (
|
||||
<div className="mb-8">
|
||||
{data.clusters.map((clusterData) => (
|
||||
<ClusterBlock key={clusterData.cluster?.objectId || 'unknown'} clusterData={clusterData} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Unassigned applications */}
|
||||
{!dataLoading && data && (data.unassigned.applications.length > 0 || data.unassigned.platforms.length > 0) && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<ClusterBlock
|
||||
isUnassigned={true}
|
||||
clusterData={{
|
||||
cluster: null,
|
||||
applications: data.unassigned.applications,
|
||||
platforms: data.unassigned.platforms,
|
||||
totalEffort: data.unassigned.totalEffort,
|
||||
applicationCount: data.unassigned.applicationCount,
|
||||
byGovernanceModel: data.unassigned.byGovernanceModel,
|
||||
}}
|
||||
/>
|
||||
<div className="px-6 pb-4">
|
||||
<p className="text-sm text-yellow-700">
|
||||
Deze applicaties zijn nog niet toegekend aan een cluster.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!dataLoading && data && hasNoApplications && (
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-lg p-8 text-center">
|
||||
<p className="text-gray-500">Geen applicaties gevonden</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
115
frontend/src/index.css
Normal file
115
frontend/src/index.css
Normal file
@@ -0,0 +1,115 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@apply bg-gray-50 text-gray-900 antialiased;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center px-4 py-2 text-sm font-medium rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply bg-white text-gray-700 border border-gray-300 hover:bg-gray-50 focus:ring-blue-500;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
@apply bg-green-600 text-white hover:bg-green-700 focus:ring-green-500;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
@apply bg-red-600 text-white hover:bg-red-700 focus:ring-red-500;
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
@apply bg-transparent text-blue-600 border border-blue-600 hover:bg-blue-50 focus:ring-blue-500;
|
||||
}
|
||||
|
||||
.input {
|
||||
@apply block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm;
|
||||
}
|
||||
|
||||
.label {
|
||||
@apply block text-sm font-medium text-gray-700;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden;
|
||||
}
|
||||
|
||||
.badge {
|
||||
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
|
||||
}
|
||||
|
||||
.badge-green {
|
||||
@apply bg-green-100 text-green-800;
|
||||
}
|
||||
|
||||
.badge-yellow {
|
||||
@apply bg-yellow-100 text-yellow-800;
|
||||
}
|
||||
|
||||
.badge-red {
|
||||
@apply bg-red-100 text-red-800;
|
||||
}
|
||||
|
||||
.badge-blue {
|
||||
@apply bg-blue-100 text-blue-800;
|
||||
}
|
||||
|
||||
.badge-gray {
|
||||
@apply bg-gray-100 text-gray-800;
|
||||
}
|
||||
|
||||
.badge-dark-red {
|
||||
@apply bg-red-800 text-white;
|
||||
}
|
||||
|
||||
.badge-light-red {
|
||||
@apply bg-red-200 text-red-900;
|
||||
}
|
||||
|
||||
.badge-dark-green {
|
||||
@apply bg-green-800 text-white;
|
||||
}
|
||||
|
||||
.badge-light-green {
|
||||
@apply bg-green-200 text-green-900;
|
||||
}
|
||||
|
||||
.badge-black {
|
||||
@apply bg-black text-white;
|
||||
}
|
||||
|
||||
.badge-darker-red {
|
||||
@apply bg-red-900 text-white;
|
||||
}
|
||||
|
||||
.badge-red {
|
||||
@apply bg-red-500 text-white;
|
||||
}
|
||||
|
||||
.badge-yellow-orange {
|
||||
@apply bg-yellow-500 text-white;
|
||||
}
|
||||
|
||||
.badge-dark-blue {
|
||||
@apply bg-blue-800 text-white;
|
||||
}
|
||||
|
||||
.badge-light-blue {
|
||||
@apply bg-blue-400 text-white;
|
||||
}
|
||||
|
||||
.badge-lighter-blue {
|
||||
@apply bg-blue-300 text-white;
|
||||
}
|
||||
}
|
||||
13
frontend/src/main.tsx
Normal file
13
frontend/src/main.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
391
frontend/src/services/api.ts
Normal file
391
frontend/src/services/api.ts
Normal file
@@ -0,0 +1,391 @@
|
||||
import type {
|
||||
ApplicationDetails,
|
||||
SearchFilters,
|
||||
SearchResult,
|
||||
AISuggestion,
|
||||
ReferenceValue,
|
||||
DashboardStats,
|
||||
ClassificationResult,
|
||||
ZiraTaxonomy,
|
||||
TeamDashboardData,
|
||||
ApplicationStatus,
|
||||
EffortCalculationBreakdown,
|
||||
} from '../types';
|
||||
|
||||
const API_BASE = '/api';
|
||||
|
||||
async function fetchApi<T>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
const response = await fetch(`${API_BASE}${endpoint}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
|
||||
throw new Error(error.error || error.message || 'API request failed');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Applications
|
||||
export async function searchApplications(
|
||||
filters: SearchFilters,
|
||||
page: number = 1,
|
||||
pageSize: number = 25
|
||||
): Promise<SearchResult> {
|
||||
return fetchApi<SearchResult>('/applications/search', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ filters, page, pageSize }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function getApplicationById(id: string): Promise<ApplicationDetails> {
|
||||
return fetchApi<ApplicationDetails>(`/applications/${id}`);
|
||||
}
|
||||
|
||||
export async function updateApplication(
|
||||
id: string,
|
||||
updates: {
|
||||
applicationFunctions?: ReferenceValue[];
|
||||
dynamicsFactor?: ReferenceValue;
|
||||
complexityFactor?: ReferenceValue;
|
||||
numberOfUsers?: ReferenceValue;
|
||||
governanceModel?: ReferenceValue;
|
||||
applicationCluster?: ReferenceValue;
|
||||
applicationType?: ReferenceValue;
|
||||
hostingType?: ReferenceValue;
|
||||
businessImpactAnalyse?: ReferenceValue;
|
||||
applicationManagementHosting?: string;
|
||||
applicationManagementTAM?: string;
|
||||
overrideFTE?: number | null;
|
||||
source?: 'AI_ACCEPTED' | 'AI_MODIFIED' | 'MANUAL';
|
||||
}
|
||||
): Promise<ApplicationDetails> {
|
||||
return fetchApi<ApplicationDetails>(`/applications/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate FTE effort for an application (real-time calculation without saving)
|
||||
export async function calculateEffort(
|
||||
applicationData: Partial<ApplicationDetails>
|
||||
): Promise<{
|
||||
requiredEffortApplicationManagement: number | null;
|
||||
breakdown: EffortCalculationBreakdown;
|
||||
}> {
|
||||
return fetchApi<{
|
||||
requiredEffortApplicationManagement: number | null;
|
||||
breakdown: EffortCalculationBreakdown;
|
||||
}>('/applications/calculate-effort', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(applicationData),
|
||||
});
|
||||
}
|
||||
|
||||
export async function getApplicationHistory(id: string): Promise<ClassificationResult[]> {
|
||||
return fetchApi<ClassificationResult[]>(`/applications/${id}/history`);
|
||||
}
|
||||
|
||||
// AI Provider type
|
||||
export type AIProvider = 'claude' | 'openai';
|
||||
|
||||
// AI Status response type
|
||||
export interface AIStatusResponse {
|
||||
available: boolean;
|
||||
providers: AIProvider[];
|
||||
defaultProvider: AIProvider;
|
||||
claude: {
|
||||
available: boolean;
|
||||
model: string;
|
||||
};
|
||||
openai: {
|
||||
available: boolean;
|
||||
model: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Classifications
|
||||
export async function getAISuggestion(id: string, provider?: AIProvider): Promise<AISuggestion> {
|
||||
const url = provider
|
||||
? `/classifications/suggest/${id}?provider=${provider}`
|
||||
: `/classifications/suggest/${id}`;
|
||||
return fetchApi<AISuggestion>(url, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export async function getTaxonomy(): Promise<ZiraTaxonomy> {
|
||||
return fetchApi<ZiraTaxonomy>('/classifications/taxonomy');
|
||||
}
|
||||
|
||||
export async function getFunctionByCode(
|
||||
code: string
|
||||
): Promise<{ domain: string; function: { code: string; name: string; description: string } }> {
|
||||
return fetchApi(`/classifications/function/${code}`);
|
||||
}
|
||||
|
||||
export async function getClassificationHistory(limit: number = 50): Promise<ClassificationResult[]> {
|
||||
return fetchApi<ClassificationResult[]>(`/classifications/history?limit=${limit}`);
|
||||
}
|
||||
|
||||
export async function getAIStatus(): Promise<AIStatusResponse> {
|
||||
return fetchApi('/classifications/ai-status');
|
||||
}
|
||||
|
||||
export async function getAIPrompt(id: string): Promise<{ prompt: string }> {
|
||||
return fetchApi(`/classifications/prompt/${id}`);
|
||||
}
|
||||
|
||||
// Reference Data
|
||||
export async function getReferenceData(): Promise<{
|
||||
dynamicsFactors: ReferenceValue[];
|
||||
complexityFactors: ReferenceValue[];
|
||||
numberOfUsers: ReferenceValue[];
|
||||
governanceModels: ReferenceValue[];
|
||||
organisations: ReferenceValue[];
|
||||
hostingTypes: ReferenceValue[];
|
||||
applicationFunctions: ReferenceValue[];
|
||||
applicationClusters: ReferenceValue[];
|
||||
applicationTypes: ReferenceValue[];
|
||||
businessImportance: ReferenceValue[];
|
||||
businessImpactAnalyses: ReferenceValue[];
|
||||
applicationManagementHosting: ReferenceValue[];
|
||||
applicationManagementTAM: ReferenceValue[];
|
||||
}> {
|
||||
return fetchApi('/reference-data');
|
||||
}
|
||||
|
||||
export async function getApplicationFunctions(): Promise<ReferenceValue[]> {
|
||||
return fetchApi<ReferenceValue[]>('/reference-data/application-functions');
|
||||
}
|
||||
|
||||
export async function getDynamicsFactors(): Promise<ReferenceValue[]> {
|
||||
return fetchApi<ReferenceValue[]>('/reference-data/dynamics-factors');
|
||||
}
|
||||
|
||||
export async function getComplexityFactors(): Promise<ReferenceValue[]> {
|
||||
return fetchApi<ReferenceValue[]>('/reference-data/complexity-factors');
|
||||
}
|
||||
|
||||
export async function getNumberOfUsers(): Promise<ReferenceValue[]> {
|
||||
return fetchApi<ReferenceValue[]>('/reference-data/number-of-users');
|
||||
}
|
||||
|
||||
export async function getGovernanceModels(): Promise<ReferenceValue[]> {
|
||||
return fetchApi<ReferenceValue[]>('/reference-data/governance-models');
|
||||
}
|
||||
|
||||
export async function getOrganisations(): Promise<ReferenceValue[]> {
|
||||
return fetchApi<ReferenceValue[]>('/reference-data/organisations');
|
||||
}
|
||||
|
||||
export async function getHostingTypes(): Promise<ReferenceValue[]> {
|
||||
return fetchApi<ReferenceValue[]>('/reference-data/hosting-types');
|
||||
}
|
||||
|
||||
export async function getApplicationClusters(): Promise<ReferenceValue[]> {
|
||||
return fetchApi<ReferenceValue[]>('/reference-data/application-clusters');
|
||||
}
|
||||
|
||||
export async function getApplicationTypes(): Promise<ReferenceValue[]> {
|
||||
return fetchApi<ReferenceValue[]>('/reference-data/application-types');
|
||||
}
|
||||
|
||||
export async function getApplicationManagementHosting(): Promise<ReferenceValue[]> {
|
||||
return fetchApi<ReferenceValue[]>('/reference-data/application-management-hosting');
|
||||
}
|
||||
|
||||
export async function getBusinessImportance(): Promise<ReferenceValue[]> {
|
||||
return fetchApi<ReferenceValue[]>('/reference-data/business-importance');
|
||||
}
|
||||
|
||||
export async function getBusinessImpactAnalyses(): Promise<ReferenceValue[]> {
|
||||
return fetchApi<ReferenceValue[]>('/reference-data/business-impact-analyses');
|
||||
}
|
||||
|
||||
// Config
|
||||
export async function getConfig(): Promise<{ jiraHost: string }> {
|
||||
return fetchApi<{ jiraHost: string }>('/config');
|
||||
}
|
||||
|
||||
// Dashboard
|
||||
export async function getDashboardStats(forceRefresh: boolean = false): Promise<DashboardStats> {
|
||||
const params = forceRefresh ? '?refresh=true' : '';
|
||||
return fetchApi<DashboardStats>(`/dashboard/stats${params}`);
|
||||
}
|
||||
|
||||
export async function getRecentClassifications(limit: number = 10): Promise<ClassificationResult[]> {
|
||||
return fetchApi<ClassificationResult[]>(`/dashboard/recent?limit=${limit}`);
|
||||
}
|
||||
|
||||
// Team Dashboard
|
||||
export async function getTeamDashboardData(excludedStatuses: ApplicationStatus[] = []): Promise<TeamDashboardData> {
|
||||
const params = new URLSearchParams();
|
||||
// Always send excludedStatuses parameter, even if empty, so backend knows the user's intent
|
||||
params.append('excludedStatuses', excludedStatuses.join(','));
|
||||
const queryString = params.toString();
|
||||
return fetchApi<TeamDashboardData>(`/applications/team-dashboard?${queryString}`);
|
||||
}
|
||||
|
||||
// Configuration
|
||||
export interface EffortCalculationConfig {
|
||||
governanceModelRules: Array<{
|
||||
governanceModel: string;
|
||||
applicationTypeRules: {
|
||||
[key: string]: {
|
||||
applicationTypes: string | string[];
|
||||
businessImpactRules: {
|
||||
[key: string]: {
|
||||
result: number;
|
||||
conditions?: {
|
||||
hostingType?: string | string[];
|
||||
};
|
||||
} | Array<{
|
||||
result: number;
|
||||
conditions?: {
|
||||
hostingType?: string | string[];
|
||||
};
|
||||
}>;
|
||||
};
|
||||
default?: {
|
||||
result: number;
|
||||
conditions?: {
|
||||
hostingType?: string | string[];
|
||||
};
|
||||
} | Array<{
|
||||
result: number;
|
||||
conditions?: {
|
||||
hostingType?: string | string[];
|
||||
};
|
||||
}>;
|
||||
};
|
||||
};
|
||||
default?: {
|
||||
result: number;
|
||||
conditions?: {
|
||||
hostingType?: string | string[];
|
||||
};
|
||||
};
|
||||
}>;
|
||||
default: {
|
||||
result: number;
|
||||
};
|
||||
}
|
||||
|
||||
export async function getEffortCalculationConfig(): Promise<EffortCalculationConfig> {
|
||||
return fetchApi<EffortCalculationConfig>('/configuration/effort-calculation');
|
||||
}
|
||||
|
||||
export async function updateEffortCalculationConfig(config: EffortCalculationConfig): Promise<{ success: boolean; message: string }> {
|
||||
return fetchApi<{ success: boolean; message: string }>('/configuration/effort-calculation', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(config),
|
||||
});
|
||||
}
|
||||
|
||||
// V25 Configuration types
|
||||
export interface FTERange {
|
||||
min: number;
|
||||
max: number;
|
||||
}
|
||||
|
||||
export interface HostingRule {
|
||||
hostingValues: string[];
|
||||
fte: FTERange;
|
||||
}
|
||||
|
||||
export interface BIALevelConfig {
|
||||
description?: string;
|
||||
defaultFte?: FTERange;
|
||||
hosting: {
|
||||
[key: string]: HostingRule;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ApplicationTypeConfigV25 {
|
||||
defaultFte?: FTERange;
|
||||
note?: string;
|
||||
requiresManualAssessment?: boolean;
|
||||
fixedFte?: boolean;
|
||||
notRecommended?: boolean;
|
||||
biaLevels: {
|
||||
[key: string]: BIALevelConfig;
|
||||
};
|
||||
}
|
||||
|
||||
export interface GovernanceModelConfigV25 {
|
||||
name: string;
|
||||
description?: string;
|
||||
allowedBia: string[];
|
||||
defaultFte: FTERange;
|
||||
note?: string;
|
||||
applicationTypes: {
|
||||
[key: string]: ApplicationTypeConfigV25;
|
||||
};
|
||||
}
|
||||
|
||||
export interface EffortCalculationConfigV25 {
|
||||
metadata: {
|
||||
version: string;
|
||||
description: string;
|
||||
date: string;
|
||||
formula: string;
|
||||
};
|
||||
regiemodellen: {
|
||||
[key: string]: GovernanceModelConfigV25;
|
||||
};
|
||||
validationRules: {
|
||||
biaRegieModelConstraints: {
|
||||
[regiemodel: string]: string[];
|
||||
};
|
||||
platformRestrictions: Array<{
|
||||
regiemodel: string;
|
||||
applicationType: string;
|
||||
warning: string;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
export async function getEffortCalculationConfigV25(): Promise<EffortCalculationConfigV25> {
|
||||
return fetchApi<EffortCalculationConfigV25>('/configuration/effort-calculation-v25');
|
||||
}
|
||||
|
||||
export async function updateEffortCalculationConfigV25(config: EffortCalculationConfigV25): Promise<{ success: boolean; message: string }> {
|
||||
return fetchApi<{ success: boolean; message: string }>('/configuration/effort-calculation-v25', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(config),
|
||||
});
|
||||
}
|
||||
|
||||
// AI Chat
|
||||
import type { ChatMessage, ChatResponse } from '../types';
|
||||
|
||||
export async function sendChatMessage(
|
||||
applicationId: string,
|
||||
message: string,
|
||||
conversationId?: string,
|
||||
provider?: AIProvider
|
||||
): Promise<ChatResponse> {
|
||||
return fetchApi<ChatResponse>(`/classifications/chat/${applicationId}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ message, conversationId, provider }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function getConversationHistory(conversationId: string): Promise<{ conversationId: string; messages: ChatMessage[] }> {
|
||||
return fetchApi<{ conversationId: string; messages: ChatMessage[] }>(`/classifications/chat/conversation/${conversationId}`);
|
||||
}
|
||||
|
||||
export async function clearConversation(conversationId: string): Promise<{ success: boolean }> {
|
||||
return fetchApi<{ success: boolean }>(`/classifications/chat/conversation/${conversationId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
91
frontend/src/stores/navigationStore.ts
Normal file
91
frontend/src/stores/navigationStore.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type { SearchFilters } from '../types';
|
||||
|
||||
interface NavigationState {
|
||||
applicationIds: string[];
|
||||
currentIndex: number;
|
||||
filters: SearchFilters;
|
||||
setNavigationContext: (ids: string[], filters: SearchFilters, currentIndex?: number) => void;
|
||||
setCurrentIndexById: (id: string) => void;
|
||||
getCurrentId: () => string | null;
|
||||
getNextId: () => string | null;
|
||||
getPreviousId: () => string | null;
|
||||
goToNext: () => void;
|
||||
goToPrevious: () => void;
|
||||
goToIndex: (index: number) => void;
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
export const useNavigationStore = create<NavigationState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
applicationIds: [],
|
||||
currentIndex: -1,
|
||||
filters: {},
|
||||
|
||||
setNavigationContext: (ids, filters, currentIndex = 0) =>
|
||||
set({
|
||||
applicationIds: ids,
|
||||
filters,
|
||||
currentIndex,
|
||||
}),
|
||||
|
||||
setCurrentIndexById: (id: string) => {
|
||||
const { applicationIds } = get();
|
||||
const index = applicationIds.indexOf(id);
|
||||
if (index !== -1) {
|
||||
set({ currentIndex: index });
|
||||
}
|
||||
},
|
||||
|
||||
getCurrentId: () => {
|
||||
const { applicationIds, currentIndex } = get();
|
||||
return currentIndex >= 0 && currentIndex < applicationIds.length
|
||||
? applicationIds[currentIndex]
|
||||
: null;
|
||||
},
|
||||
|
||||
getNextId: () => {
|
||||
const { applicationIds, currentIndex } = get();
|
||||
return currentIndex + 1 < applicationIds.length
|
||||
? applicationIds[currentIndex + 1]
|
||||
: null;
|
||||
},
|
||||
|
||||
getPreviousId: () => {
|
||||
const { applicationIds, currentIndex } = get();
|
||||
return currentIndex > 0 ? applicationIds[currentIndex - 1] : null;
|
||||
},
|
||||
|
||||
goToNext: () =>
|
||||
set((state) => ({
|
||||
currentIndex:
|
||||
state.currentIndex + 1 < state.applicationIds.length
|
||||
? state.currentIndex + 1
|
||||
: state.currentIndex,
|
||||
})),
|
||||
|
||||
goToPrevious: () =>
|
||||
set((state) => ({
|
||||
currentIndex: state.currentIndex > 0 ? state.currentIndex - 1 : 0,
|
||||
})),
|
||||
|
||||
goToIndex: (index) =>
|
||||
set((state) => ({
|
||||
currentIndex:
|
||||
index >= 0 && index < state.applicationIds.length ? index : state.currentIndex,
|
||||
})),
|
||||
|
||||
clear: () =>
|
||||
set({
|
||||
applicationIds: [],
|
||||
currentIndex: -1,
|
||||
filters: {},
|
||||
}),
|
||||
}),
|
||||
{
|
||||
name: 'zira-navigation-context',
|
||||
}
|
||||
)
|
||||
);
|
||||
130
frontend/src/stores/searchStore.ts
Normal file
130
frontend/src/stores/searchStore.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { create } from 'zustand';
|
||||
import type { SearchFilters, ApplicationStatus } from '../types';
|
||||
|
||||
interface SearchState {
|
||||
filters: SearchFilters;
|
||||
currentPage: number;
|
||||
pageSize: number;
|
||||
setSearchText: (text: string) => void;
|
||||
setStatuses: (statuses: ApplicationStatus[]) => void;
|
||||
setApplicationFunction: (value: 'all' | 'filled' | 'empty') => void;
|
||||
setGovernanceModel: (value: 'all' | 'filled' | 'empty') => void;
|
||||
setDynamicsFactor: (value: 'all' | 'filled' | 'empty') => void;
|
||||
setComplexityFactor: (value: 'all' | 'filled' | 'empty') => void;
|
||||
setApplicationCluster: (value: 'all' | 'filled' | 'empty') => void;
|
||||
setApplicationType: (value: 'all' | 'filled' | 'empty') => void;
|
||||
setOrganisation: (value: string | undefined) => void;
|
||||
setHostingType: (value: string | undefined) => void;
|
||||
setBusinessImportance: (value: string | undefined) => void;
|
||||
setCurrentPage: (page: number) => void;
|
||||
setPageSize: (size: number) => void;
|
||||
resetFilters: () => void;
|
||||
}
|
||||
|
||||
// Default statuses: all except "Closed"
|
||||
const defaultStatuses: ApplicationStatus[] = [
|
||||
'In Production',
|
||||
'Implementation',
|
||||
'Proof of Concept',
|
||||
'End of support',
|
||||
'End of life',
|
||||
'Deprecated',
|
||||
'Shadow IT',
|
||||
'Undefined',
|
||||
];
|
||||
|
||||
const defaultFilters: SearchFilters = {
|
||||
searchText: '',
|
||||
statuses: defaultStatuses,
|
||||
applicationFunction: 'all',
|
||||
governanceModel: 'all',
|
||||
dynamicsFactor: 'all',
|
||||
complexityFactor: 'all',
|
||||
applicationCluster: 'all',
|
||||
applicationType: 'all',
|
||||
organisation: undefined,
|
||||
hostingType: undefined,
|
||||
businessImportance: undefined,
|
||||
};
|
||||
|
||||
export const useSearchStore = create<SearchState>((set) => ({
|
||||
filters: { ...defaultFilters },
|
||||
currentPage: 1,
|
||||
pageSize: 25,
|
||||
|
||||
setSearchText: (text) =>
|
||||
set((state) => ({
|
||||
filters: { ...state.filters, searchText: text },
|
||||
currentPage: 1,
|
||||
})),
|
||||
|
||||
setStatuses: (statuses) =>
|
||||
set((state) => ({
|
||||
filters: { ...state.filters, statuses },
|
||||
currentPage: 1,
|
||||
})),
|
||||
|
||||
setApplicationFunction: (value) =>
|
||||
set((state) => ({
|
||||
filters: { ...state.filters, applicationFunction: value },
|
||||
currentPage: 1,
|
||||
})),
|
||||
|
||||
setGovernanceModel: (value) =>
|
||||
set((state) => ({
|
||||
filters: { ...state.filters, governanceModel: value },
|
||||
currentPage: 1,
|
||||
})),
|
||||
|
||||
setDynamicsFactor: (value) =>
|
||||
set((state) => ({
|
||||
filters: { ...state.filters, dynamicsFactor: value },
|
||||
currentPage: 1,
|
||||
})),
|
||||
|
||||
setComplexityFactor: (value) =>
|
||||
set((state) => ({
|
||||
filters: { ...state.filters, complexityFactor: value },
|
||||
currentPage: 1,
|
||||
})),
|
||||
|
||||
setApplicationCluster: (value) =>
|
||||
set((state) => ({
|
||||
filters: { ...state.filters, applicationCluster: value },
|
||||
currentPage: 1,
|
||||
})),
|
||||
|
||||
setApplicationType: (value) =>
|
||||
set((state) => ({
|
||||
filters: { ...state.filters, applicationType: value },
|
||||
currentPage: 1,
|
||||
})),
|
||||
|
||||
setOrganisation: (value) =>
|
||||
set((state) => ({
|
||||
filters: { ...state.filters, organisation: value },
|
||||
currentPage: 1,
|
||||
})),
|
||||
|
||||
setHostingType: (value) =>
|
||||
set((state) => ({
|
||||
filters: { ...state.filters, hostingType: value },
|
||||
currentPage: 1,
|
||||
})),
|
||||
|
||||
setBusinessImportance: (value) =>
|
||||
set((state) => ({
|
||||
filters: { ...state.filters, businessImportance: value },
|
||||
currentPage: 1,
|
||||
})),
|
||||
|
||||
setCurrentPage: (page) => set({ currentPage: page }),
|
||||
|
||||
setPageSize: (size) => set({ pageSize: size, currentPage: 1 }),
|
||||
|
||||
resetFilters: () =>
|
||||
set({
|
||||
filters: { ...defaultFilters },
|
||||
currentPage: 1,
|
||||
}),
|
||||
}));
|
||||
344
frontend/src/types/index.ts
Normal file
344
frontend/src/types/index.ts
Normal file
@@ -0,0 +1,344 @@
|
||||
// Application status types
|
||||
export type ApplicationStatus =
|
||||
| 'Status'
|
||||
| 'Closed'
|
||||
| 'Deprecated'
|
||||
| 'End of life'
|
||||
| 'End of support'
|
||||
| 'Implementation'
|
||||
| 'In Production'
|
||||
| 'Proof of Concept'
|
||||
| 'Shadow IT'
|
||||
| 'Undefined';
|
||||
|
||||
// Reference value from Jira Assets
|
||||
export interface ReferenceValue {
|
||||
objectId: string;
|
||||
key: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
summary?: string; // Summary attribute for Dynamics Factor, Complexity Factor, and Governance Model
|
||||
category?: string; // Deprecated: kept for backward compatibility, use applicationFunctionCategory instead
|
||||
applicationFunctionCategory?: ReferenceValue; // Reference to ApplicationFunctionCategory object
|
||||
keywords?: string; // Keywords for ApplicationFunction
|
||||
order?: number;
|
||||
factor?: number; // Factor attribute for Dynamics Factor, Complexity Factor, and Number of Users
|
||||
remarks?: string; // Remarks attribute for Governance Model
|
||||
application?: string; // Application attribute for Governance Model
|
||||
indicators?: string; // Indicators attribute for Business Impact Analyse
|
||||
}
|
||||
|
||||
// Application list item (summary view)
|
||||
export interface ApplicationListItem {
|
||||
id: string;
|
||||
key: string;
|
||||
name: string;
|
||||
status: ApplicationStatus | null;
|
||||
applicationFunctions: ReferenceValue[]; // Multiple functions supported
|
||||
governanceModel: ReferenceValue | null;
|
||||
dynamicsFactor: ReferenceValue | null;
|
||||
complexityFactor: ReferenceValue | null;
|
||||
applicationCluster: ReferenceValue | null;
|
||||
applicationType: ReferenceValue | null;
|
||||
platform: ReferenceValue | null; // Reference to parent Platform Application Component
|
||||
requiredEffortApplicationManagement: number | null; // Calculated field
|
||||
minFTE?: number | null; // Minimum FTE from configuration range
|
||||
maxFTE?: number | null; // Maximum FTE from configuration range
|
||||
overrideFTE?: number | null; // Override FTE value (if set, overrides calculated value)
|
||||
applicationManagementHosting?: ReferenceValue | null; // Application Management - Hosting
|
||||
applicationManagementTAM?: ReferenceValue | null; // Application Management - TAM
|
||||
}
|
||||
|
||||
// Full application details
|
||||
export interface ApplicationDetails {
|
||||
id: string;
|
||||
key: string;
|
||||
name: string;
|
||||
searchReference: string | null;
|
||||
description: string | null;
|
||||
supplierProduct: string | null;
|
||||
organisation: string | null;
|
||||
hostingType: ReferenceValue | null;
|
||||
status: ApplicationStatus | null;
|
||||
businessImportance: string | null;
|
||||
businessImpactAnalyse: ReferenceValue | null;
|
||||
systemOwner: string | null;
|
||||
businessOwner: string | null;
|
||||
functionalApplicationManagement: string | null;
|
||||
technicalApplicationManagement: string | null;
|
||||
technicalApplicationManagementPrimary?: string | null; // Technical Application Management Primary
|
||||
technicalApplicationManagementSecondary?: string | null; // Technical Application Management Secondary
|
||||
medischeTechniek: boolean;
|
||||
applicationFunctions: ReferenceValue[]; // Multiple functions supported
|
||||
dynamicsFactor: ReferenceValue | null;
|
||||
complexityFactor: ReferenceValue | null;
|
||||
numberOfUsers: ReferenceValue | null;
|
||||
governanceModel: ReferenceValue | null;
|
||||
applicationCluster: ReferenceValue | null;
|
||||
applicationType: ReferenceValue | null;
|
||||
platform: ReferenceValue | null; // Reference to parent Platform Application Component
|
||||
requiredEffortApplicationManagement: number | null; // Calculated field
|
||||
overrideFTE?: number | null; // Override FTE value (if set, overrides calculated value)
|
||||
applicationManagementHosting?: ReferenceValue | null; // Application Management - Hosting
|
||||
applicationManagementTAM?: ReferenceValue | null; // Application Management - TAM
|
||||
technischeArchitectuur?: string | null; // URL to Technical Architecture document (Attribute ID 572)
|
||||
}
|
||||
|
||||
// Search filters
|
||||
export interface SearchFilters {
|
||||
searchText?: string;
|
||||
statuses?: ApplicationStatus[];
|
||||
applicationFunction?: 'all' | 'filled' | 'empty';
|
||||
governanceModel?: 'all' | 'filled' | 'empty';
|
||||
dynamicsFactor?: 'all' | 'filled' | 'empty';
|
||||
complexityFactor?: 'all' | 'filled' | 'empty';
|
||||
applicationCluster?: 'all' | 'filled' | 'empty';
|
||||
applicationType?: 'all' | 'filled' | 'empty';
|
||||
organisation?: string;
|
||||
hostingType?: string;
|
||||
businessImportance?: string;
|
||||
}
|
||||
|
||||
// Paginated search result
|
||||
export interface SearchResult {
|
||||
applications: ApplicationListItem[];
|
||||
totalCount: number;
|
||||
currentPage: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
// AI classification suggestion
|
||||
export interface AISuggestion {
|
||||
primaryFunction: {
|
||||
code: string;
|
||||
name: string;
|
||||
reasoning: string;
|
||||
};
|
||||
secondaryFunctions: Array<{
|
||||
code: string;
|
||||
name: string;
|
||||
reasoning: string;
|
||||
}>;
|
||||
managementClassification?: {
|
||||
applicationType?: {
|
||||
value: string;
|
||||
reasoning: string;
|
||||
};
|
||||
dynamicsFactor?: {
|
||||
value: string;
|
||||
label: string;
|
||||
reasoning: string;
|
||||
};
|
||||
complexityFactor?: {
|
||||
value: string;
|
||||
label: string;
|
||||
reasoning: string;
|
||||
};
|
||||
hostingType?: {
|
||||
value: string;
|
||||
reasoning: string;
|
||||
};
|
||||
applicationManagementHosting?: {
|
||||
value: string;
|
||||
reasoning: string;
|
||||
};
|
||||
applicationManagementTAM?: {
|
||||
value: string;
|
||||
reasoning: string;
|
||||
};
|
||||
biaClassification?: {
|
||||
value: string;
|
||||
reasoning: string;
|
||||
};
|
||||
governanceModel?: {
|
||||
value: string;
|
||||
reasoning: string;
|
||||
};
|
||||
};
|
||||
validationWarnings?: string[];
|
||||
confidence: 'HOOG' | 'MIDDEN' | 'LAAG';
|
||||
notes: string;
|
||||
}
|
||||
|
||||
// Pending changes for an application
|
||||
export interface PendingChanges {
|
||||
applicationFunctions?: { from: ReferenceValue[]; to: ReferenceValue[] };
|
||||
dynamicsFactor?: { from: ReferenceValue | null; to: ReferenceValue };
|
||||
complexityFactor?: { from: ReferenceValue | null; to: ReferenceValue };
|
||||
numberOfUsers?: { from: ReferenceValue | null; to: ReferenceValue };
|
||||
governanceModel?: { from: ReferenceValue | null; to: ReferenceValue };
|
||||
applicationCluster?: { from: ReferenceValue | null; to: ReferenceValue };
|
||||
applicationType?: { from: ReferenceValue | null; to: ReferenceValue };
|
||||
}
|
||||
|
||||
// Classification result for audit log
|
||||
export interface ClassificationResult {
|
||||
applicationId: string;
|
||||
applicationName: string;
|
||||
changes: PendingChanges;
|
||||
source: 'AI_ACCEPTED' | 'AI_MODIFIED' | 'MANUAL';
|
||||
timestamp: Date;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
// Reference options for dropdowns
|
||||
export interface ReferenceOptions {
|
||||
dynamicsFactors: ReferenceValue[];
|
||||
complexityFactors: ReferenceValue[];
|
||||
numberOfUsers: ReferenceValue[];
|
||||
governanceModels: ReferenceValue[];
|
||||
applicationFunctions: ReferenceValue[];
|
||||
applicationClusters: ReferenceValue[];
|
||||
applicationTypes: ReferenceValue[];
|
||||
organisations: ReferenceValue[];
|
||||
hostingTypes: ReferenceValue[];
|
||||
businessImportance: ReferenceValue[];
|
||||
}
|
||||
|
||||
// ZiRA domain structure
|
||||
export interface ZiraDomain {
|
||||
code: string;
|
||||
name: string;
|
||||
description: string;
|
||||
functions: ZiraFunction[];
|
||||
}
|
||||
|
||||
export interface ZiraFunction {
|
||||
code: string;
|
||||
name: string;
|
||||
description: string;
|
||||
keywords: string[];
|
||||
}
|
||||
|
||||
export interface ZiraTaxonomy {
|
||||
version: string;
|
||||
source: string;
|
||||
lastUpdated: string;
|
||||
domains: ZiraDomain[];
|
||||
}
|
||||
|
||||
// Dashboard statistics
|
||||
export interface DashboardStats {
|
||||
totalApplications: number;
|
||||
classifiedCount: number;
|
||||
unclassifiedCount: number;
|
||||
byStatus: Record<string, number>;
|
||||
byDomain: Record<string, number>;
|
||||
byGovernanceModel: Record<string, number>;
|
||||
recentClassifications: ClassificationResult[];
|
||||
}
|
||||
|
||||
// Navigation state for detail screen
|
||||
export interface NavigationState {
|
||||
currentIndex: number;
|
||||
totalInResults: number;
|
||||
applicationIds: string[];
|
||||
filters: SearchFilters;
|
||||
}
|
||||
|
||||
// Effort calculation breakdown
|
||||
// Effort calculation breakdown (v25)
|
||||
export interface EffortCalculationBreakdown {
|
||||
// Base FTE values
|
||||
baseEffort: number; // Average of min/max
|
||||
baseEffortMin: number;
|
||||
baseEffortMax: number;
|
||||
|
||||
// Lookup path used
|
||||
governanceModel: string | null;
|
||||
governanceModelName: string | null;
|
||||
applicationType: string | null;
|
||||
businessImpactAnalyse: string | null;
|
||||
applicationManagementHosting: string | null;
|
||||
|
||||
// Factors applied
|
||||
numberOfUsersFactor: { value: number; name: string | null };
|
||||
dynamicsFactor: { value: number; name: string | null };
|
||||
complexityFactor: { value: number; name: string | null };
|
||||
|
||||
// Fallback information
|
||||
usedDefaults: string[]; // Which levels used default values
|
||||
|
||||
// Validation warnings/errors
|
||||
warnings: string[];
|
||||
errors: string[];
|
||||
|
||||
// Special flags
|
||||
requiresManualAssessment: boolean;
|
||||
isFixedFte: boolean;
|
||||
notRecommended: boolean;
|
||||
|
||||
// Hours calculation (based on final FTE)
|
||||
hoursPerYear: number;
|
||||
hoursPerMonth: number;
|
||||
hoursPerWeek: number;
|
||||
}
|
||||
|
||||
// Team dashboard types
|
||||
export interface PlatformWithWorkloads {
|
||||
platform: ApplicationListItem;
|
||||
workloads: ApplicationListItem[];
|
||||
platformEffort: number;
|
||||
workloadsEffort: number;
|
||||
totalEffort: number; // platformEffort + workloadsEffort
|
||||
}
|
||||
|
||||
export interface TeamDashboardCluster {
|
||||
cluster: ReferenceValue | null;
|
||||
applications: ApplicationListItem[]; // Regular applications (non-Platform, non-Workload)
|
||||
platforms: PlatformWithWorkloads[]; // Platforms with their workloads
|
||||
totalEffort: number; // Sum of all applications + platforms + workloads
|
||||
minEffort: number; // Sum of all minimum FTE values
|
||||
maxEffort: number; // Sum of all maximum FTE values
|
||||
applicationCount: number; // Count of all applications (including platforms and workloads)
|
||||
byGovernanceModel: Record<string, number>; // Distribution per governance model
|
||||
}
|
||||
|
||||
export interface TeamDashboardData {
|
||||
clusters: TeamDashboardCluster[];
|
||||
unassigned: {
|
||||
applications: ApplicationListItem[]; // Regular applications (non-Platform, non-Workload)
|
||||
platforms: PlatformWithWorkloads[]; // Platforms with their workloads
|
||||
totalEffort: number; // Sum of all applications + platforms + workloads
|
||||
minEffort: number; // Sum of all minimum FTE values
|
||||
maxEffort: number; // Sum of all maximum FTE values
|
||||
applicationCount: number; // Count of all applications (including platforms and workloads)
|
||||
byGovernanceModel: Record<string, number>; // Distribution per governance model
|
||||
};
|
||||
}
|
||||
|
||||
// Chat message for AI conversation
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
// For assistant messages, include the structured suggestion if available
|
||||
suggestion?: AISuggestion;
|
||||
}
|
||||
|
||||
// Chat conversation state
|
||||
export interface ChatConversation {
|
||||
id: string;
|
||||
applicationId: string;
|
||||
applicationName: string;
|
||||
messages: ChatMessage[];
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
// Chat request for follow-up
|
||||
export interface ChatRequest {
|
||||
conversationId?: string; // If continuing existing conversation
|
||||
applicationId: string;
|
||||
message: string;
|
||||
provider?: 'claude' | 'openai';
|
||||
}
|
||||
|
||||
// Chat response
|
||||
export interface ChatResponse {
|
||||
conversationId: string;
|
||||
message: ChatMessage;
|
||||
suggestion?: AISuggestion; // Updated suggestion if AI provided one
|
||||
}
|
||||
31
frontend/tailwind.config.js
Normal file
31
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,31 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#eff6ff',
|
||||
100: '#dbeafe',
|
||||
200: '#bfdbfe',
|
||||
300: '#93c5fd',
|
||||
400: '#60a5fa',
|
||||
500: '#3b82f6',
|
||||
600: '#2563eb',
|
||||
700: '#1d4ed8',
|
||||
800: '#1e40af',
|
||||
900: '#1e3a8a',
|
||||
},
|
||||
zuyderland: {
|
||||
primary: '#003366',
|
||||
secondary: '#006699',
|
||||
accent: '#00a3e0',
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
25
frontend/tsconfig.json
Normal file
25
frontend/tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
11
frontend/tsconfig.node.json
Normal file
11
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
21
frontend/vite.config.ts
Normal file
21
frontend/vite.config.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user