feat: dashboard widgets, auth fix, profile names, dynamic groups
- Add VehicleDashboardCard: self-contained widget modelled after AtemschutzDashboardCard, shows einsatzbereit ratio and inspection warnings inline; replaces StatsCard + InspectionAlerts in Dashboard - Add EquipmentDashboardCard: consolidated equipment status widget showing only aggregated counts (no per-item listing); replaces EquipmentAlerts component in Dashboard - Fix auth race condition: add authInitialized flag to api.ts so 401 responses during initial token validation no longer trigger a spurious redirect to /login; save intended destination before login redirect and restore it after successful auth callback - Fix profile firstname/lastname: add extractNames() helper to auth.controller.ts that falls back to splitting userinfo.name when Authentik does not provide separate given_name/family_name fields; applied on both create and update paths - Dynamic groups endpoint: replace hardcoded KNOWN_GROUPS array in events.controller.ts with a DB query (SELECT DISTINCT unnest (authentik_groups) FROM users); known slugs get German labels via lookup map, unknown slugs are humanized automatically Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -30,8 +30,10 @@ const LoginCallback: React.FC = () => {
|
||||
|
||||
try {
|
||||
await login(code);
|
||||
// Redirect to dashboard on success
|
||||
navigate('/dashboard', { replace: true });
|
||||
// Navigate to the originally intended page, falling back to the dashboard
|
||||
const from = sessionStorage.getItem('auth_redirect_from') || '/dashboard';
|
||||
sessionStorage.removeItem('auth_redirect_from');
|
||||
navigate(from, { replace: true });
|
||||
} catch (err) {
|
||||
console.error('Login callback error:', err);
|
||||
const is429 = err && typeof err === 'object' && 'status' in err && (err as any).status === 429;
|
||||
|
||||
@@ -33,7 +33,7 @@ const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
|
||||
|
||||
// If not authenticated, redirect to login
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />;
|
||||
return <Navigate to="/login" replace state={{ from: window.location.pathname + window.location.search }} />;
|
||||
}
|
||||
|
||||
// User is authenticated, render children
|
||||
|
||||
168
frontend/src/components/equipment/EquipmentDashboardCard.tsx
Normal file
168
frontend/src/components/equipment/EquipmentDashboardCard.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
AlertTitle,
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
CircularProgress,
|
||||
Link,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import { equipmentApi } from '../../services/equipment';
|
||||
import type { EquipmentStats } from '../../types/equipment.types';
|
||||
|
||||
interface EquipmentDashboardCardProps {
|
||||
hideWhenEmpty?: boolean;
|
||||
}
|
||||
|
||||
const EquipmentDashboardCard: React.FC<EquipmentDashboardCardProps> = ({
|
||||
hideWhenEmpty = false,
|
||||
}) => {
|
||||
const [stats, setStats] = useState<EquipmentStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const data = await equipmentApi.getStats();
|
||||
if (mounted) setStats(data);
|
||||
} catch {
|
||||
if (mounted) setError('Ausrüstungsstatus konnte nicht geladen werden.');
|
||||
} finally {
|
||||
if (mounted) setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchStats();
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 2 }}>
|
||||
<CircularProgress size={16} />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Ausrüstungsstatus wird geladen...
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="body2" color="error">
|
||||
{error}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!stats) return null;
|
||||
|
||||
const hasCritical =
|
||||
stats.wichtigNichtBereit > 0 || stats.inspectionsOverdue > 0 || stats.beschaedigt > 0;
|
||||
|
||||
const hasWarning =
|
||||
stats.inspectionsDue > 0 || stats.inWartung > 0 || stats.ausserDienst > 0;
|
||||
|
||||
const allGood = stats.einsatzbereit === stats.total && !hasCritical && !hasWarning;
|
||||
|
||||
if (hideWhenEmpty && allGood) return null;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Ausrüstung
|
||||
</Typography>
|
||||
|
||||
{/* Main metric */}
|
||||
<Typography variant="h4" fontWeight={700} color={allGood ? 'success.main' : 'text.primary'}>
|
||||
{stats.einsatzbereit}/{stats.total}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
|
||||
einsatzbereit
|
||||
</Typography>
|
||||
|
||||
{/* Alerts — consolidated counts only */}
|
||||
{(hasCritical || hasWarning) && (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, mt: 1 }}>
|
||||
{hasCritical && (
|
||||
<Alert severity="error" variant="outlined" sx={{ py: 0.5 }}>
|
||||
<AlertTitle sx={{ fontWeight: 600, mb: 0.5 }}>Kritisch</AlertTitle>
|
||||
{stats.wichtigNichtBereit > 0 && (
|
||||
<Typography variant="body2">
|
||||
{stats.wichtigNichtBereit} wichtige Teile nicht einsatzbereit
|
||||
</Typography>
|
||||
)}
|
||||
{stats.inspectionsOverdue > 0 && (
|
||||
<Typography variant="body2">
|
||||
{stats.inspectionsOverdue} Prüfung{stats.inspectionsOverdue !== 1 ? 'en' : ''} überfällig
|
||||
</Typography>
|
||||
)}
|
||||
{stats.beschaedigt > 0 && (
|
||||
<Typography variant="body2">
|
||||
{stats.beschaedigt} beschädigt
|
||||
</Typography>
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
{hasWarning && (
|
||||
<Alert severity="warning" variant="outlined" sx={{ py: 0.5 }}>
|
||||
<AlertTitle sx={{ fontWeight: 600, mb: 0.5 }}>Achtung</AlertTitle>
|
||||
{stats.inspectionsDue > 0 && (
|
||||
<Typography variant="body2">
|
||||
{stats.inspectionsDue} Prüfung{stats.inspectionsDue !== 1 ? 'en' : ''} bald fällig
|
||||
</Typography>
|
||||
)}
|
||||
{stats.inWartung > 0 && (
|
||||
<Typography variant="body2">
|
||||
{stats.inWartung} in Wartung
|
||||
</Typography>
|
||||
)}
|
||||
{stats.ausserDienst > 0 && (
|
||||
<Typography variant="body2">
|
||||
{stats.ausserDienst} außer Dienst
|
||||
</Typography>
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* All good message */}
|
||||
{allGood && (
|
||||
<Typography variant="body2" color="success.main">
|
||||
Alle Ausrüstung einsatzbereit
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* Link to management page */}
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Link
|
||||
component={RouterLink}
|
||||
to="/ausruestung"
|
||||
underline="hover"
|
||||
variant="body2"
|
||||
>
|
||||
Zur Verwaltung
|
||||
</Link>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default EquipmentDashboardCard;
|
||||
162
frontend/src/components/vehicles/VehicleDashboardCard.tsx
Normal file
162
frontend/src/components/vehicles/VehicleDashboardCard.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
AlertTitle,
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
CircularProgress,
|
||||
Link,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import { vehiclesApi } from '../../services/vehicles';
|
||||
import type { VehicleStats, InspectionAlert } from '../../types/vehicle.types';
|
||||
|
||||
interface VehicleDashboardCardProps {
|
||||
hideWhenEmpty?: boolean;
|
||||
}
|
||||
|
||||
const VehicleDashboardCard: React.FC<VehicleDashboardCardProps> = ({
|
||||
hideWhenEmpty = false,
|
||||
}) => {
|
||||
const [stats, setStats] = useState<VehicleStats | null>(null);
|
||||
const [alerts, setAlerts] = useState<InspectionAlert[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const [statsData, alertsData] = await Promise.all([
|
||||
vehiclesApi.getStats(),
|
||||
vehiclesApi.getAlerts(30),
|
||||
]);
|
||||
if (mounted) {
|
||||
setStats(statsData);
|
||||
setAlerts(alertsData);
|
||||
}
|
||||
} catch {
|
||||
if (mounted) setError('Fahrzeugstatus konnte nicht geladen werden.');
|
||||
} finally {
|
||||
if (mounted) setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 2 }}>
|
||||
<CircularProgress size={16} />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Fahrzeugstatus wird geladen...
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="body2" color="error">
|
||||
{error}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!stats) return null;
|
||||
|
||||
const overdueAlerts = alerts.filter((a) => a.tage < 0);
|
||||
const upcomingAlerts = alerts.filter((a) => a.tage >= 0 && a.tage <= 30);
|
||||
|
||||
const hasConcerns =
|
||||
overdueAlerts.length > 0 ||
|
||||
upcomingAlerts.length > 0 ||
|
||||
stats.ausserDienst > 0;
|
||||
|
||||
const allGood = stats.einsatzbereit === stats.total && !hasConcerns;
|
||||
|
||||
// If hideWhenEmpty and everything is fine, render nothing
|
||||
if (hideWhenEmpty && allGood) return null;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Fahrzeuge
|
||||
</Typography>
|
||||
|
||||
{/* Main metric */}
|
||||
<Typography variant="h4" fontWeight={700} color={allGood ? 'success.main' : 'text.primary'}>
|
||||
{stats.einsatzbereit}/{stats.total}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
|
||||
einsatzbereit
|
||||
</Typography>
|
||||
|
||||
{/* Concerns list — using Alert components for consistent warning styling */}
|
||||
{hasConcerns && (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, mt: 1 }}>
|
||||
{overdueAlerts.length > 0 && (
|
||||
<Alert severity="error" variant="outlined" sx={{ py: 0.5 }}>
|
||||
<AlertTitle sx={{ fontWeight: 600, mb: 0.5 }}>Überfällig</AlertTitle>
|
||||
<Typography variant="body2">
|
||||
{overdueAlerts.length} Prüfung{overdueAlerts.length !== 1 ? 'en' : ''} überfällig
|
||||
</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
{stats.ausserDienst > 0 && (
|
||||
<Alert severity="error" variant="outlined" sx={{ py: 0.5 }}>
|
||||
<AlertTitle sx={{ fontWeight: 600, mb: 0.5 }}>Außer Dienst</AlertTitle>
|
||||
<Typography variant="body2">
|
||||
{stats.ausserDienst} Fahrzeug{stats.ausserDienst !== 1 ? 'e' : ''} außer Dienst
|
||||
</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
{upcomingAlerts.length > 0 && (
|
||||
<Alert severity="warning" variant="outlined" sx={{ py: 0.5 }}>
|
||||
<AlertTitle sx={{ fontWeight: 600, mb: 0.5 }}>Bald fällig</AlertTitle>
|
||||
<Typography variant="body2">
|
||||
{upcomingAlerts.length} Prüfung{upcomingAlerts.length !== 1 ? 'en' : ''} bald fällig
|
||||
</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* All good message */}
|
||||
{allGood && (
|
||||
<Typography variant="body2" color="success.main">
|
||||
Alle Fahrzeuge einsatzbereit
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* Link to management page */}
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Link
|
||||
component={RouterLink}
|
||||
to="/fahrzeuge"
|
||||
underline="hover"
|
||||
variant="body2"
|
||||
>
|
||||
Zur Verwaltung
|
||||
</Link>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default VehicleDashboardCard;
|
||||
Reference in New Issue
Block a user