Files
dashboard/frontend/src/components/vehicles/VehicleDashboardCard.tsx
Matthias Hochmeister 3101f1a9c5 fix: five dashboard improvements across booking, vehicles, profile, and UI
- fix(auth): guard extractNames() against Authentik sending full name in
  given_name field (e.g. "Matthias Hochmeister" + family_name "Hochmeister");
  detect by checking given_name ends with family_name suffix, fall through
  to name-splitting so Vorname/Nachname display correctly in Profile

- fix(db): add migration 018 to repair broken BEFORE UPDATE triggers on
  veranstaltungen and veranstaltung_kategorien; old triggers called
  update_updated_at_column() which references NEW.updated_at, but both
  tables use aktualisiert_am, causing every category/event edit to fail

- feat(booking): open vehicle booking creation to all authenticated users;
  only dashboard_admin / dashboard_moderator can change the Buchungsart
  (type select disabled for regular members); edit and cancel still
  restricted to WRITE_GROUPS

- feat(vehicles): VehicleDashboardCard now fetches equipment warnings via
  equipmentApi.getVehicleWarnings() in parallel and shows an alert when
  any vehicle equipment is not einsatzbereit

- fix(ui): add MuiTextField defaultProps (InputLabelProps.shrink=true) and
  MuiOutlinedInput notch legend font-size override to theme to eliminate
  floating-label / border conflict on click

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 11:04:57 +01:00

177 lines
5.6 KiB
TypeScript

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 { equipmentApi } from '../../services/equipment';
import type { VehicleStats, InspectionAlert } from '../../types/vehicle.types';
import type { VehicleEquipmentWarning } from '../../types/equipment.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 [equipmentWarnings, setEquipmentWarnings] = useState<VehicleEquipmentWarning[]>([]);
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, warningsData] = await Promise.all([
vehiclesApi.getStats(),
vehiclesApi.getAlerts(30),
equipmentApi.getVehicleWarnings(),
]);
if (mounted) {
setStats(statsData);
setAlerts(alertsData);
setEquipmentWarnings(warningsData);
}
} 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 ||
equipmentWarnings.length > 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>
)}
{equipmentWarnings.length > 0 && (
<Alert severity="warning" variant="outlined" sx={{ py: 0.5 }}>
<AlertTitle sx={{ fontWeight: 600, mb: 0.5 }}>Ausrüstung nicht verfügbar</AlertTitle>
<Typography variant="body2">
{equipmentWarnings.length} Ausrüstungsgegenstand{equipmentWarnings.length !== 1 ? 'stände' : 'stand'} nicht einsatzbereit
</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;