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:
Matthias Hochmeister
2026-03-03 10:28:31 +01:00
parent 831927ae90
commit 2306741c4d
11 changed files with 470 additions and 110 deletions

View File

@@ -1,57 +1,32 @@
import { useState, useEffect } from 'react';
import {
Alert,
Container,
Box,
Typography,
Grid,
Fade,
} from '@mui/material';
import {
DirectionsCar,
} from '@mui/icons-material';
import { useAuth } from '../contexts/AuthContext';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import SkeletonCard from '../components/shared/SkeletonCard';
import UserProfile from '../components/dashboard/UserProfile';
import NextcloudTalkWidget from '../components/dashboard/NextcloudTalkWidget';
import UpcomingEventsWidget from '../components/dashboard/UpcomingEventsWidget';
import StatsCard from '../components/dashboard/StatsCard';
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 EquipmentDashboardCard from '../components/equipment/EquipmentDashboardCard';
import VehicleDashboardCard from '../components/vehicles/VehicleDashboardCard';
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() {
const { user } = useAuth();
const [dataLoading, setDataLoading] = useState(true);
const [vehicleStats, setVehicleStats] = useState<VehicleStats | null>(null);
const [vehicleWarnings, setVehicleWarnings] = useState<VehicleEquipmentWarning[]>([]);
useEffect(() => {
const timer = setTimeout(() => {
setDataLoading(false);
}, 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);
}, []);
@@ -100,63 +75,20 @@ function Dashboard() {
</Grid>
)}
{/* Live vehicle KPI — einsatzbereit count from API */}
<Grid item xs={12} sm={6} md={3}>
{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}>
{/* Vehicle Status Card */}
<Grid item xs={12} md={6}>
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '380ms' }}>
<Box>
<InspectionAlerts daysAhead={30} hideWhenEmpty={true} />
<VehicleDashboardCard />
</Box>
</Fade>
</Grid>
{/* Equipment Alerts Panel */}
<Grid item xs={12}>
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '400ms' }}>
{/* Equipment Status Card */}
<Grid item xs={12} md={6}>
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '450ms' }}>
<Box>
<EquipmentAlerts daysAhead={30} hideWhenEmpty={true} />
<EquipmentDashboardCard />
</Box>
</Fade>
</Grid>