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:
@@ -7,6 +7,41 @@ import { AuthRequest } from '../types/auth.types';
|
|||||||
import auditService, { AuditAction, AuditResourceType } from '../services/audit.service';
|
import auditService, { AuditAction, AuditResourceType } from '../services/audit.service';
|
||||||
import { extractIp, extractUserAgent } from '../middleware/audit.middleware';
|
import { extractIp, extractUserAgent } from '../middleware/audit.middleware';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract given_name and family_name from Authentik userinfo.
|
||||||
|
* Falls back to splitting the `name` field if individual fields are missing or identical.
|
||||||
|
*/
|
||||||
|
function extractNames(userInfo: { name?: string; given_name?: string; family_name?: string }): {
|
||||||
|
given_name: string | undefined;
|
||||||
|
family_name: string | undefined;
|
||||||
|
} {
|
||||||
|
const givenName = userInfo.given_name?.trim();
|
||||||
|
const familyName = userInfo.family_name?.trim();
|
||||||
|
|
||||||
|
// If Authentik provides both and they differ, use them directly
|
||||||
|
if (givenName && familyName && givenName !== familyName) {
|
||||||
|
return { given_name: givenName, family_name: familyName };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to splitting the name field
|
||||||
|
if (userInfo.name) {
|
||||||
|
const parts = userInfo.name.trim().split(/\s+/);
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
return {
|
||||||
|
given_name: parts[0],
|
||||||
|
family_name: parts.slice(1).join(' '),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Single word name — use as given_name only
|
||||||
|
return {
|
||||||
|
given_name: parts[0],
|
||||||
|
family_name: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { given_name: givenName, family_name: familyName };
|
||||||
|
}
|
||||||
|
|
||||||
class AuthController {
|
class AuthController {
|
||||||
/**
|
/**
|
||||||
* Handle OAuth callback
|
* Handle OAuth callback
|
||||||
@@ -56,12 +91,14 @@ class AuthController {
|
|||||||
email: userInfo.email,
|
email: userInfo.email,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { given_name: newGivenName, family_name: newFamilyName } = extractNames(userInfo);
|
||||||
|
|
||||||
user = await userService.createUser({
|
user = await userService.createUser({
|
||||||
email: userInfo.email,
|
email: userInfo.email,
|
||||||
authentik_sub: userInfo.sub,
|
authentik_sub: userInfo.sub,
|
||||||
preferred_username: userInfo.preferred_username,
|
preferred_username: userInfo.preferred_username,
|
||||||
given_name: userInfo.given_name,
|
given_name: newGivenName,
|
||||||
family_name: userInfo.family_name,
|
family_name: newFamilyName,
|
||||||
name: userInfo.name,
|
name: userInfo.name,
|
||||||
profile_picture_url: userInfo.picture,
|
profile_picture_url: userInfo.picture,
|
||||||
});
|
});
|
||||||
@@ -91,11 +128,13 @@ class AuthController {
|
|||||||
await userService.updateLastLogin(user.id);
|
await userService.updateLastLogin(user.id);
|
||||||
await userService.updateGroups(user.id, groups);
|
await userService.updateGroups(user.id, groups);
|
||||||
|
|
||||||
|
const { given_name: updatedGivenName, family_name: updatedFamilyName } = extractNames(userInfo);
|
||||||
|
|
||||||
// Refresh profile fields from Authentik on every login
|
// Refresh profile fields from Authentik on every login
|
||||||
await userService.updateUser(user.id, {
|
await userService.updateUser(user.id, {
|
||||||
name: userInfo.name,
|
name: userInfo.name,
|
||||||
given_name: userInfo.given_name,
|
given_name: updatedGivenName,
|
||||||
family_name: userInfo.family_name,
|
family_name: updatedFamilyName,
|
||||||
preferred_username: userInfo.preferred_username,
|
preferred_username: userInfo.preferred_username,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -114,6 +153,9 @@ class AuthController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract normalised names once for use in the response
|
||||||
|
const { given_name: resolvedGivenName, family_name: resolvedFamilyName } = extractNames(userInfo);
|
||||||
|
|
||||||
// Check if user is active
|
// Check if user is active
|
||||||
if (!user.is_active) {
|
if (!user.is_active) {
|
||||||
logger.warn('Inactive user attempted login', { userId: user.id });
|
logger.warn('Inactive user attempted login', { userId: user.id });
|
||||||
@@ -170,8 +212,8 @@ class AuthController {
|
|||||||
email: user.email,
|
email: user.email,
|
||||||
name: userInfo.name || user.name,
|
name: userInfo.name || user.name,
|
||||||
preferredUsername: userInfo.preferred_username || user.preferred_username,
|
preferredUsername: userInfo.preferred_username || user.preferred_username,
|
||||||
givenName: userInfo.given_name || user.given_name,
|
givenName: resolvedGivenName || user.given_name,
|
||||||
familyName: userInfo.family_name || user.family_name,
|
familyName: resolvedFamilyName || user.family_name,
|
||||||
profilePictureUrl: user.profile_picture_url,
|
profilePictureUrl: user.profile_picture_url,
|
||||||
isActive: user.is_active,
|
isActive: user.is_active,
|
||||||
groups,
|
groups,
|
||||||
|
|||||||
@@ -9,21 +9,6 @@ import {
|
|||||||
} from '../models/events.model';
|
} from '../models/events.model';
|
||||||
import logger from '../utils/logger';
|
import logger from '../utils/logger';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Known Authentik groups exposed to the frontend for event targeting
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const KNOWN_GROUPS = [
|
|
||||||
{ id: 'dashboard_admin', label: 'Administratoren' },
|
|
||||||
{ id: 'dashboard_moderator', label: 'Moderatoren' },
|
|
||||||
{ id: 'dashboard_mitglied', label: 'Mitglieder' },
|
|
||||||
{ id: 'dashboard_fahrmeister', label: 'Fahrmeister' },
|
|
||||||
{ id: 'dashboard_zeugmeister', label: 'Zeugmeister' },
|
|
||||||
{ id: 'dashboard_atemschutz', label: 'Atemschutzwart' },
|
|
||||||
{ id: 'dashboard_jugend', label: 'Feuerwehrjugend' },
|
|
||||||
{ id: 'dashboard_kommandant', label: 'Kommandanten' },
|
|
||||||
];
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Helper — extract userGroups from request
|
// Helper — extract userGroups from request
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -124,7 +109,13 @@ class EventsController {
|
|||||||
// GET /api/events/groups
|
// GET /api/events/groups
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
getAvailableGroups = async (_req: Request, res: Response): Promise<void> => {
|
getAvailableGroups = async (_req: Request, res: Response): Promise<void> => {
|
||||||
res.json({ success: true, data: KNOWN_GROUPS });
|
try {
|
||||||
|
const groups = await eventsService.getAvailableGroups();
|
||||||
|
res.json({ success: true, data: groups });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('getAvailableGroups error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Fehler beim Laden der Gruppen' });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -430,6 +430,48 @@ class EventsService {
|
|||||||
return { token, subscribeUrl };
|
return { token, subscribeUrl };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// GROUPS
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns distinct group slugs from active users as { id, label } pairs.
|
||||||
|
* The label is derived from a known-translations map or humanized from the slug.
|
||||||
|
*/
|
||||||
|
async getAvailableGroups(): Promise<Array<{ id: string; label: string }>> {
|
||||||
|
const knownLabels: Record<string, string> = {
|
||||||
|
'dashboard_admin': 'Administratoren',
|
||||||
|
'dashboard_mitglied': 'Mitglieder',
|
||||||
|
'dashboard_fahrmeister': 'Fahrmeister',
|
||||||
|
'dashboard_zeugmeister': 'Zeugmeister',
|
||||||
|
'dashboard_atemschutz': 'Atemschutzwart',
|
||||||
|
'dashboard_jugend': 'Feuerwehrjugend',
|
||||||
|
'dashboard_kommandant': 'Kommandanten',
|
||||||
|
'dashboard_moderator': 'Moderatoren',
|
||||||
|
'feuerwehr-admin': 'Feuerwehr Admin',
|
||||||
|
'feuerwehr-kommandant': 'Feuerwehr Kommandant',
|
||||||
|
};
|
||||||
|
|
||||||
|
const humanizeGroupName = (slug: string): string => {
|
||||||
|
if (knownLabels[slug]) return knownLabels[slug];
|
||||||
|
const stripped = slug.startsWith('dashboard_') ? slug.slice('dashboard_'.length) : slug;
|
||||||
|
const spaced = stripped.replace(/-/g, ' ');
|
||||||
|
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT DISTINCT unnest(authentik_groups) AS group_name
|
||||||
|
FROM users
|
||||||
|
WHERE is_active = true
|
||||||
|
ORDER BY group_name`
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows.map((row) => ({
|
||||||
|
id: row.group_name as string,
|
||||||
|
label: humanizeGroupName(row.group_name as string),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// ICAL EXPORT
|
// ICAL EXPORT
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -30,8 +30,10 @@ const LoginCallback: React.FC = () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await login(code);
|
await login(code);
|
||||||
// Redirect to dashboard on success
|
// Navigate to the originally intended page, falling back to the dashboard
|
||||||
navigate('/dashboard', { replace: true });
|
const from = sessionStorage.getItem('auth_redirect_from') || '/dashboard';
|
||||||
|
sessionStorage.removeItem('auth_redirect_from');
|
||||||
|
navigate(from, { replace: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Login callback error:', err);
|
console.error('Login callback error:', err);
|
||||||
const is429 = err && typeof err === 'object' && 'status' in err && (err as any).status === 429;
|
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 not authenticated, redirect to login
|
||||||
if (!isAuthenticated) {
|
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
|
// 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;
|
||||||
@@ -3,6 +3,7 @@ import { AuthContextType, AuthState, User } from '../types/auth.types';
|
|||||||
import { authService } from '../services/auth';
|
import { authService } from '../services/auth';
|
||||||
import { getToken, setToken, removeToken, getUser, setUser, removeUser } from '../utils/storage';
|
import { getToken, setToken, removeToken, getUser, setUser, removeUser } from '../utils/storage';
|
||||||
import { useNotification } from './NotificationContext';
|
import { useNotification } from './NotificationContext';
|
||||||
|
import { setAuthInitialized } from '../services/api';
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||||
|
|
||||||
@@ -47,6 +48,8 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
|||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
});
|
});
|
||||||
|
} finally {
|
||||||
|
setAuthInitialized(true);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setState({
|
setState({
|
||||||
@@ -55,6 +58,7 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
|||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
});
|
});
|
||||||
|
setAuthInitialized(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,57 +1,32 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
Alert,
|
|
||||||
Container,
|
Container,
|
||||||
Box,
|
Box,
|
||||||
Typography,
|
Typography,
|
||||||
Grid,
|
Grid,
|
||||||
Fade,
|
Fade,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import {
|
|
||||||
DirectionsCar,
|
|
||||||
} from '@mui/icons-material';
|
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||||
import SkeletonCard from '../components/shared/SkeletonCard';
|
import SkeletonCard from '../components/shared/SkeletonCard';
|
||||||
import UserProfile from '../components/dashboard/UserProfile';
|
import UserProfile from '../components/dashboard/UserProfile';
|
||||||
import NextcloudTalkWidget from '../components/dashboard/NextcloudTalkWidget';
|
import NextcloudTalkWidget from '../components/dashboard/NextcloudTalkWidget';
|
||||||
import UpcomingEventsWidget from '../components/dashboard/UpcomingEventsWidget';
|
import UpcomingEventsWidget from '../components/dashboard/UpcomingEventsWidget';
|
||||||
import StatsCard from '../components/dashboard/StatsCard';
|
|
||||||
import ActivityFeed from '../components/dashboard/ActivityFeed';
|
import ActivityFeed from '../components/dashboard/ActivityFeed';
|
||||||
import InspectionAlerts from '../components/vehicles/InspectionAlerts';
|
|
||||||
import EquipmentAlerts from '../components/equipment/EquipmentAlerts';
|
|
||||||
import AtemschutzDashboardCard from '../components/atemschutz/AtemschutzDashboardCard';
|
import AtemschutzDashboardCard from '../components/atemschutz/AtemschutzDashboardCard';
|
||||||
|
import EquipmentDashboardCard from '../components/equipment/EquipmentDashboardCard';
|
||||||
|
import VehicleDashboardCard from '../components/vehicles/VehicleDashboardCard';
|
||||||
import PersonalWarningsBanner from '../components/dashboard/PersonalWarningsBanner';
|
import PersonalWarningsBanner from '../components/dashboard/PersonalWarningsBanner';
|
||||||
import { vehiclesApi } from '../services/vehicles';
|
|
||||||
import { equipmentApi } from '../services/equipment';
|
|
||||||
import type { VehicleStats } from '../types/vehicle.types';
|
|
||||||
import type { VehicleEquipmentWarning } from '../types/equipment.types';
|
|
||||||
|
|
||||||
function Dashboard() {
|
function Dashboard() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [dataLoading, setDataLoading] = useState(true);
|
const [dataLoading, setDataLoading] = useState(true);
|
||||||
const [vehicleStats, setVehicleStats] = useState<VehicleStats | null>(null);
|
|
||||||
const [vehicleWarnings, setVehicleWarnings] = useState<VehicleEquipmentWarning[]>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
setDataLoading(false);
|
setDataLoading(false);
|
||||||
}, 800);
|
}, 800);
|
||||||
|
|
||||||
// Fetch live vehicle stats for the KPI strip
|
|
||||||
vehiclesApi.getStats()
|
|
||||||
.then((stats) => setVehicleStats(stats))
|
|
||||||
.catch(() => {
|
|
||||||
// Non-critical — KPI will fall back to placeholder
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fetch vehicle equipment warnings
|
|
||||||
equipmentApi.getVehicleWarnings()
|
|
||||||
.then((w) => setVehicleWarnings(w))
|
|
||||||
.catch(() => {
|
|
||||||
// Non-critical — warning indicator simply won't appear
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -100,63 +75,20 @@ function Dashboard() {
|
|||||||
</Grid>
|
</Grid>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Live vehicle KPI — einsatzbereit count from API */}
|
{/* Vehicle Status Card */}
|
||||||
<Grid item xs={12} sm={6} md={3}>
|
<Grid item xs={12} md={6}>
|
||||||
{dataLoading ? (
|
|
||||||
<SkeletonCard variant="basic" />
|
|
||||||
) : (
|
|
||||||
<Fade in={true} timeout={600} style={{ transitionDelay: '200ms' }}>
|
|
||||||
<Box>
|
|
||||||
<StatsCard
|
|
||||||
title="Fahrzeuge einsatzbereit"
|
|
||||||
value={
|
|
||||||
vehicleStats
|
|
||||||
? `${vehicleStats?.einsatzbereit}/${vehicleStats?.total}`
|
|
||||||
: '—'
|
|
||||||
}
|
|
||||||
icon={DirectionsCar}
|
|
||||||
color="success.main"
|
|
||||||
/>
|
|
||||||
{vehicleWarnings.length > 0 && (() => {
|
|
||||||
const errorCount = vehicleWarnings.filter(w =>
|
|
||||||
w.status === 'beschaedigt' || w.status === 'ausser_dienst'
|
|
||||||
).length;
|
|
||||||
const warnCount = vehicleWarnings.filter(w =>
|
|
||||||
w.status === 'in_wartung'
|
|
||||||
).length;
|
|
||||||
const vehicleCount = new Set(vehicleWarnings.map(w => w.fahrzeug_id)).size;
|
|
||||||
const severity = errorCount > 0 ? 'error' : 'warning';
|
|
||||||
return (
|
|
||||||
<Alert
|
|
||||||
severity={severity}
|
|
||||||
variant="outlined"
|
|
||||||
sx={{ mt: 1, py: 0.5, '& .MuiAlert-message': { fontSize: '0.8rem' } }}
|
|
||||||
>
|
|
||||||
{vehicleCount} Fahrzeug{vehicleCount !== 1 ? 'e' : ''} mit Ausrüstungsmangel
|
|
||||||
{errorCount > 0 && ` (${errorCount} kritisch)`}
|
|
||||||
{warnCount > 0 && errorCount === 0 && ` (${warnCount} in Wartung)`}
|
|
||||||
</Alert>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</Box>
|
|
||||||
</Fade>
|
|
||||||
)}
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
{/* Inspection Alerts Panel — safety-critical, shown immediately */}
|
|
||||||
<Grid item xs={12}>
|
|
||||||
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '380ms' }}>
|
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '380ms' }}>
|
||||||
<Box>
|
<Box>
|
||||||
<InspectionAlerts daysAhead={30} hideWhenEmpty={true} />
|
<VehicleDashboardCard />
|
||||||
</Box>
|
</Box>
|
||||||
</Fade>
|
</Fade>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
{/* Equipment Alerts Panel */}
|
{/* Equipment Status Card */}
|
||||||
<Grid item xs={12}>
|
<Grid item xs={12} md={6}>
|
||||||
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '400ms' }}>
|
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '450ms' }}>
|
||||||
<Box>
|
<Box>
|
||||||
<EquipmentAlerts daysAhead={30} hideWhenEmpty={true} />
|
<EquipmentDashboardCard />
|
||||||
</Box>
|
</Box>
|
||||||
</Fade>
|
</Fade>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Container,
|
Container,
|
||||||
Box,
|
Box,
|
||||||
@@ -15,6 +15,7 @@ import { useAuth } from '../contexts/AuthContext';
|
|||||||
|
|
||||||
function Login() {
|
function Login() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
const { isAuthenticated, isLoading } = useAuth();
|
const { isAuthenticated, isLoading } = useAuth();
|
||||||
const [isRedirecting, setIsRedirecting] = useState(false);
|
const [isRedirecting, setIsRedirecting] = useState(false);
|
||||||
|
|
||||||
@@ -22,12 +23,19 @@ function Login() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated) {
|
if (isAuthenticated) {
|
||||||
setIsRedirecting(true);
|
setIsRedirecting(true);
|
||||||
navigate('/dashboard', { replace: true });
|
const from = (location.state as any)?.from || '/dashboard';
|
||||||
|
navigate(from, { replace: true });
|
||||||
}
|
}
|
||||||
}, [isAuthenticated, navigate]);
|
}, [isAuthenticated, navigate, location.state]);
|
||||||
|
|
||||||
const handleLogin = () => {
|
const handleLogin = () => {
|
||||||
try {
|
try {
|
||||||
|
// Persist the intended destination so LoginCallback can restore it
|
||||||
|
// after the full-page Authentik redirect round-trip
|
||||||
|
const from = (location.state as any)?.from;
|
||||||
|
if (from) {
|
||||||
|
sessionStorage.setItem('auth_redirect_from', from);
|
||||||
|
}
|
||||||
const authUrl = authService.getAuthUrl();
|
const authUrl = authService.getAuthUrl();
|
||||||
window.location.href = authUrl;
|
window.location.href = authUrl;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -2,6 +2,12 @@ import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse, AxiosError } f
|
|||||||
import { API_URL } from '../utils/config';
|
import { API_URL } from '../utils/config';
|
||||||
import { getToken, removeToken, removeUser } from '../utils/storage';
|
import { getToken, removeToken, removeUser } from '../utils/storage';
|
||||||
|
|
||||||
|
let authInitialized = false;
|
||||||
|
|
||||||
|
export function setAuthInitialized(value: boolean): void {
|
||||||
|
authInitialized = value;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ApiError {
|
export interface ApiError {
|
||||||
message: string;
|
message: string;
|
||||||
status?: number;
|
status?: number;
|
||||||
@@ -40,12 +46,15 @@ class ApiService {
|
|||||||
(response) => response,
|
(response) => response,
|
||||||
async (error: AxiosError) => {
|
async (error: AxiosError) => {
|
||||||
if (error.response?.status === 401) {
|
if (error.response?.status === 401) {
|
||||||
|
if (authInitialized) {
|
||||||
// Clear tokens and redirect to login
|
// Clear tokens and redirect to login
|
||||||
console.warn('Unauthorized request, redirecting to login');
|
console.warn('Unauthorized request, redirecting to login');
|
||||||
removeToken();
|
removeToken();
|
||||||
removeUser();
|
removeUser();
|
||||||
window.location.href = '/login';
|
window.location.href = '/login';
|
||||||
}
|
}
|
||||||
|
// During initialization, silently reject without redirecting
|
||||||
|
}
|
||||||
|
|
||||||
// Retry on 429 (Too Many Requests) with exponential backoff
|
// Retry on 429 (Too Many Requests) with exponential backoff
|
||||||
if (error.response?.status === 429 && error.config) {
|
if (error.response?.status === 429 && error.config) {
|
||||||
|
|||||||
Reference in New Issue
Block a user