- Replace 'TEAM' label with Type attribute (Business/Enabling/Staf) in team blocks - Make Type labels larger (text-sm) and brighter colors - Make SUBTEAM label less bright (indigo-300) and smaller (text-[10px]) - Add 'FTE' suffix to bandbreedte values in header and application blocks - Add Platform and Connected Device labels to application blocks - Show Platform FTE and Workloads FTE separately in Platform blocks - Add spacing between Regiemodel letter and count value - Add cache invalidation for Team Dashboard when applications are updated - Enrich team references with Type attribute in getSubteamToTeamMapping
330 lines
12 KiB
TypeScript
330 lines
12 KiB
TypeScript
import { useEffect, useState, useRef } from 'react';
|
|
import { Routes, Route, Link, useLocation, Navigate, useParams } from 'react-router-dom';
|
|
import { clsx } from 'clsx';
|
|
import SearchDashboard from './components/SearchDashboard';
|
|
import Dashboard from './components/Dashboard';
|
|
import ApplicationList from './components/ApplicationList';
|
|
import ApplicationInfo from './components/ApplicationInfo';
|
|
import GovernanceModelHelper from './components/GovernanceModelHelper';
|
|
import TeamDashboard from './components/TeamDashboard';
|
|
import ConfigurationV25 from './components/ConfigurationV25';
|
|
import ReportsDashboard from './components/ReportsDashboard';
|
|
import GovernanceAnalysis from './components/GovernanceAnalysis';
|
|
import DataModelDashboard from './components/DataModelDashboard';
|
|
import FTECalculator from './components/FTECalculator';
|
|
import Login from './components/Login';
|
|
import { useAuthStore } from './stores/authStore';
|
|
|
|
// Redirect component for old app-components/overview/:id paths
|
|
function RedirectToApplicationEdit() {
|
|
const { id } = useParams<{ id: string }>();
|
|
return <Navigate to={`/application/${id}/edit`} replace />;
|
|
}
|
|
|
|
// Dropdown menu item type
|
|
interface NavItem {
|
|
path: string;
|
|
label: string;
|
|
exact?: boolean;
|
|
}
|
|
|
|
interface NavDropdown {
|
|
label: string;
|
|
icon?: React.ReactNode;
|
|
items: NavItem[];
|
|
basePath: string;
|
|
}
|
|
|
|
// Dropdown component for navigation
|
|
function NavDropdown({ dropdown, isActive }: { dropdown: NavDropdown; isActive: boolean }) {
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
const location = useLocation();
|
|
|
|
// Close dropdown when clicking outside
|
|
useEffect(() => {
|
|
function handleClickOutside(event: MouseEvent) {
|
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
|
setIsOpen(false);
|
|
}
|
|
}
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
}, []);
|
|
|
|
// Close dropdown on route change
|
|
useEffect(() => {
|
|
setIsOpen(false);
|
|
}, [location.pathname]);
|
|
|
|
return (
|
|
<div className="relative" ref={dropdownRef}>
|
|
<button
|
|
onClick={() => setIsOpen(!isOpen)}
|
|
className={clsx(
|
|
'flex items-center gap-1 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'
|
|
)}
|
|
>
|
|
{dropdown.label}
|
|
<svg
|
|
className={clsx('w-4 h-4 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>
|
|
</button>
|
|
|
|
{isOpen && (
|
|
<div className="absolute left-0 mt-1 w-48 bg-white rounded-lg shadow-lg border border-gray-200 py-1 z-50">
|
|
{dropdown.items.map((item) => {
|
|
const itemActive = item.exact
|
|
? location.pathname === item.path
|
|
: location.pathname.startsWith(item.path);
|
|
|
|
return (
|
|
<Link
|
|
key={item.path}
|
|
to={item.path}
|
|
className={clsx(
|
|
'block px-4 py-2 text-sm transition-colors',
|
|
itemActive
|
|
? 'bg-blue-50 text-blue-700'
|
|
: 'text-gray-700 hover:bg-gray-50'
|
|
)}
|
|
>
|
|
{item.label}
|
|
</Link>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function UserMenu() {
|
|
const { user, authMethod, logout } = useAuthStore();
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
|
|
if (!user) return null;
|
|
|
|
const initials = user.displayName
|
|
.split(' ')
|
|
.map(n => n[0])
|
|
.join('')
|
|
.toUpperCase()
|
|
.slice(0, 2);
|
|
|
|
return (
|
|
<div className="relative">
|
|
<button
|
|
onClick={() => setIsOpen(!isOpen)}
|
|
className="flex items-center gap-2 px-3 py-1.5 rounded-lg hover:bg-gray-100 transition-colors"
|
|
>
|
|
{user.avatarUrl ? (
|
|
<img
|
|
src={user.avatarUrl}
|
|
alt={user.displayName}
|
|
className="w-8 h-8 rounded-full"
|
|
/>
|
|
) : (
|
|
<div className="w-8 h-8 rounded-full bg-blue-600 flex items-center justify-center">
|
|
<span className="text-white text-sm font-medium">{initials}</span>
|
|
</div>
|
|
)}
|
|
<span className="text-sm text-gray-700 hidden sm:block">{user.displayName}</span>
|
|
<svg className="w-4 h-4 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>
|
|
</button>
|
|
|
|
{isOpen && (
|
|
<>
|
|
<div
|
|
className="fixed inset-0 z-10"
|
|
onClick={() => setIsOpen(false)}
|
|
/>
|
|
<div className="absolute right-0 mt-2 w-56 bg-white rounded-lg shadow-lg border border-gray-200 z-20">
|
|
<div className="px-4 py-3 border-b border-gray-100">
|
|
<p className="text-sm font-medium text-gray-900">{user.displayName}</p>
|
|
{user.emailAddress && (
|
|
<p className="text-xs text-gray-500 truncate">{user.emailAddress}</p>
|
|
)}
|
|
<p className="text-xs text-gray-400 mt-1">
|
|
{authMethod === 'oauth' ? 'Jira OAuth' : 'Service Account'}
|
|
</p>
|
|
</div>
|
|
<div className="py-1">
|
|
{authMethod === 'oauth' && (
|
|
<button
|
|
onClick={() => {
|
|
setIsOpen(false);
|
|
logout();
|
|
}}
|
|
className="w-full px-4 py-2 text-left text-sm text-red-600 hover:bg-red-50 transition-colors"
|
|
>
|
|
Uitloggen
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function AppContent() {
|
|
const location = useLocation();
|
|
|
|
// Navigation structure
|
|
const appComponentsDropdown: NavDropdown = {
|
|
label: 'Application Component',
|
|
basePath: '/application',
|
|
items: [
|
|
{ path: '/app-components', label: 'Dashboard', exact: true },
|
|
{ path: '/application/overview', label: 'Overzicht', exact: false },
|
|
{ path: '/application/fte-calculator', label: 'FTE Calculator', exact: true },
|
|
{ path: '/app-components/fte-config', label: 'FTE Config', exact: true },
|
|
],
|
|
};
|
|
|
|
const reportsDropdown: NavDropdown = {
|
|
label: 'Rapporten',
|
|
basePath: '/reports',
|
|
items: [
|
|
{ path: '/reports', label: 'Overzicht', exact: true },
|
|
{ path: '/reports/team-dashboard', label: 'Team-indeling', exact: true },
|
|
{ path: '/reports/governance-analysis', label: 'Analyse Regiemodel', exact: true },
|
|
{ path: '/reports/data-model', label: 'Datamodel', exact: true },
|
|
],
|
|
};
|
|
|
|
const isAppComponentsActive = location.pathname.startsWith('/app-components') || location.pathname.startsWith('/application');
|
|
const isReportsActive = location.pathname.startsWith('/reports');
|
|
const isDashboardActive = location.pathname === '/';
|
|
|
|
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">
|
|
<Link to="/" className="flex items-center space-x-3">
|
|
<img src="/logo-zuyderland.svg" alt="Zuyderland" className="w-9 h-9" />
|
|
<div>
|
|
<h1 className="text-lg font-semibold text-gray-900">
|
|
Analyse Tool
|
|
</h1>
|
|
<p className="text-xs text-gray-500">Zuyderland CMDB</p>
|
|
</div>
|
|
</Link>
|
|
|
|
<nav className="hidden md:flex items-center space-x-1">
|
|
{/* Dashboard (Search) */}
|
|
<Link
|
|
to="/"
|
|
className={clsx(
|
|
'px-3 py-2 rounded-md text-sm font-medium transition-colors',
|
|
isDashboardActive
|
|
? 'bg-blue-50 text-blue-700'
|
|
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'
|
|
)}
|
|
>
|
|
Dashboard
|
|
</Link>
|
|
|
|
{/* Application Component Dropdown */}
|
|
<NavDropdown dropdown={appComponentsDropdown} isActive={isAppComponentsActive} />
|
|
|
|
{/* Reports Dropdown */}
|
|
<NavDropdown dropdown={reportsDropdown} isActive={isReportsActive} />
|
|
</nav>
|
|
</div>
|
|
|
|
<UserMenu />
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
{/* Main content */}
|
|
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
<Routes>
|
|
{/* Main Dashboard (Search) */}
|
|
<Route path="/" element={<SearchDashboard />} />
|
|
|
|
{/* Application routes (new structure) */}
|
|
<Route path="/application/overview" element={<ApplicationList />} />
|
|
<Route path="/application/fte-calculator" element={<FTECalculator />} />
|
|
<Route path="/application/:id" element={<ApplicationInfo />} />
|
|
<Route path="/application/:id/edit" element={<GovernanceModelHelper />} />
|
|
|
|
{/* Application Component routes */}
|
|
<Route path="/app-components" element={<Dashboard />} />
|
|
<Route path="/app-components/fte-config" element={<ConfigurationV25 />} />
|
|
|
|
{/* Reports routes */}
|
|
<Route path="/reports" element={<ReportsDashboard />} />
|
|
<Route path="/reports/team-dashboard" element={<TeamDashboard />} />
|
|
<Route path="/reports/governance-analysis" element={<GovernanceAnalysis />} />
|
|
<Route path="/reports/data-model" element={<DataModelDashboard />} />
|
|
|
|
{/* Legacy redirects for bookmarks - redirect old paths to new ones */}
|
|
<Route path="/app-components/overview" element={<Navigate to="/application/overview" replace />} />
|
|
<Route path="/app-components/overview/:id" element={<RedirectToApplicationEdit />} />
|
|
<Route path="/applications" element={<Navigate to="/application/overview" replace />} />
|
|
<Route path="/applications/:id" element={<RedirectToApplicationEdit />} />
|
|
<Route path="/teams" element={<TeamDashboard />} />
|
|
<Route path="/configuration" element={<ConfigurationV25 />} />
|
|
</Routes>
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function App() {
|
|
const { isAuthenticated, isLoading, checkAuth, fetchConfig, config } = useAuthStore();
|
|
|
|
useEffect(() => {
|
|
// Fetch auth config first, then check auth status
|
|
const init = async () => {
|
|
await fetchConfig();
|
|
await checkAuth();
|
|
};
|
|
init();
|
|
}, [fetchConfig, checkAuth]);
|
|
|
|
// Show loading state
|
|
if (isLoading) {
|
|
return (
|
|
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 flex items-center justify-center">
|
|
<div className="text-center">
|
|
<div className="w-12 h-12 border-4 border-cyan-500 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
|
|
<p className="text-slate-400">Laden...</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Show login if OAuth is enabled and not authenticated
|
|
if (config?.authMethod === 'oauth' && !isAuthenticated) {
|
|
return <Login />;
|
|
}
|
|
|
|
// Show login if nothing is configured
|
|
if (config?.authMethod === 'none') {
|
|
return <Login />;
|
|
}
|
|
|
|
// Show main app
|
|
return <AppContent />;
|
|
}
|
|
|
|
export default App;
|