add now features

This commit is contained in:
Matthias Hochmeister
2026-03-01 14:41:45 +01:00
parent e76946ed8a
commit 5b8f40ab9a
14 changed files with 2044 additions and 84 deletions

View File

@@ -0,0 +1,148 @@
import React, { useEffect, useState } from 'react';
import {
Box,
Card,
CardContent,
CircularProgress,
Link,
Typography,
} from '@mui/material';
import { Link as RouterLink } from 'react-router-dom';
import { atemschutzApi } from '../../services/atemschutz';
import type { AtemschutzStats } from '../../types/atemschutz.types';
interface AtemschutzDashboardCardProps {
hideWhenEmpty?: boolean;
}
const AtemschutzDashboardCard: React.FC<AtemschutzDashboardCardProps> = ({
hideWhenEmpty = false,
}) => {
const [stats, setStats] = useState<AtemschutzStats | 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 atemschutzApi.getStats();
if (mounted) setStats(data);
} catch {
if (mounted) setError('Atemschutzstatus 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">
Atemschutzstatus wird geladen...
</Typography>
</CardContent>
</Card>
);
}
if (error) {
return (
<Card>
<CardContent>
<Typography variant="body2" color="error">
{error}
</Typography>
</CardContent>
</Card>
);
}
if (!stats) return null;
// Determine if there are any concerns
const hasConcerns =
stats.untersuchungAbgelaufen > 0 ||
stats.leistungstestAbgelaufen > 0 ||
stats.untersuchungBaldFaellig > 0 ||
stats.leistungstestBaldFaellig > 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>
Atemschutz
</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 */}
{hasConcerns && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{stats.untersuchungAbgelaufen > 0 && (
<Typography variant="body2" color="error.main">
{stats.untersuchungAbgelaufen} Untersuchung{stats.untersuchungAbgelaufen !== 1 ? 'en' : ''} abgelaufen
</Typography>
)}
{stats.leistungstestAbgelaufen > 0 && (
<Typography variant="body2" color="error.main">
{stats.leistungstestAbgelaufen} Leistungstest{stats.leistungstestAbgelaufen !== 1 ? 's' : ''} abgelaufen
</Typography>
)}
{stats.untersuchungBaldFaellig > 0 && (
<Typography variant="body2" color="warning.main">
{stats.untersuchungBaldFaellig} Untersuchung{stats.untersuchungBaldFaellig !== 1 ? 'en' : ''} bald fällig
</Typography>
)}
{stats.leistungstestBaldFaellig > 0 && (
<Typography variant="body2" color="warning.main">
{stats.leistungstestBaldFaellig} Leistungstest{stats.leistungstestBaldFaellig !== 1 ? 's' : ''} bald fällig
</Typography>
)}
</Box>
)}
{/* All good message */}
{allGood && (
<Typography variant="body2" color="success.main">
Alle Atemschutzträger einsatzbereit
</Typography>
)}
{/* Link to management page */}
<Box sx={{ mt: 2 }}>
<Link
component={RouterLink}
to="/atemschutz"
underline="hover"
variant="body2"
>
Zur Verwaltung
</Link>
</Box>
</CardContent>
</Card>
);
};
export default AtemschutzDashboardCard;

View File

@@ -0,0 +1,251 @@
import React, { useEffect, useState } from 'react';
import {
Alert,
AlertTitle,
Box,
CircularProgress,
Link,
Typography,
} from '@mui/material';
import { Link as RouterLink } from 'react-router-dom';
import { equipmentApi } from '../../services/equipment';
import {
AusruestungStatusLabel,
AusruestungStatus,
} from '../../types/equipment.types';
import type {
EquipmentStats,
AusruestungListItem,
} from '../../types/equipment.types';
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
}
interface EquipmentAlertsProps {
daysAhead?: number;
hideWhenEmpty?: boolean;
}
interface AlertGroup {
key: string;
severity: 'error' | 'warning';
title: string;
content: React.ReactNode;
}
const EquipmentAlerts: React.FC<EquipmentAlertsProps> = ({
daysAhead = 30,
hideWhenEmpty = true,
}) => {
const [stats, setStats] = useState<EquipmentStats | null>(null);
const [alerts, setAlerts] = useState<AusruestungListItem[]>([]);
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([
equipmentApi.getStats(),
equipmentApi.getAlerts(daysAhead),
]);
if (mounted) {
setStats(statsData);
setAlerts(alertsData);
}
} catch {
if (mounted) setError('Ausrüstungshinweise konnten nicht geladen werden.');
} finally {
if (mounted) setLoading(false);
}
};
fetchData();
return () => { mounted = false; };
}, [daysAhead]);
if (loading) {
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 1 }}>
<CircularProgress size={16} />
<Typography variant="body2" color="text.secondary">
Ausrüstungsstatus wird geprüft...
</Typography>
</Box>
);
}
if (error) {
return <Alert severity="error">{error}</Alert>;
}
if (!stats) return null;
// Separate alerts into overdue vs. upcoming inspections
const overdueItems = alerts.filter(
(a) => a.pruefung_tage_bis_faelligkeit !== null && a.pruefung_tage_bis_faelligkeit < 0
);
const upcomingItems = alerts.filter(
(a) => a.pruefung_tage_bis_faelligkeit !== null && a.pruefung_tage_bis_faelligkeit >= 0
);
// Build alert groups based on stats
const groups: AlertGroup[] = [];
// 1. Overdue inspections
if (stats.inspectionsOverdue > 0 && overdueItems.length > 0) {
groups.push({
key: 'overdue',
severity: 'error',
title: `Überfällige Prüfungen (${stats.inspectionsOverdue})`,
content: (
<Box component="ul" sx={{ m: 0, pl: 2 }}>
{overdueItems.map((item) => {
const tage = Math.abs(item.pruefung_tage_bis_faelligkeit!);
const tageText = `seit ${tage} Tag${tage === 1 ? '' : 'en'} überfällig`;
return (
<Box key={item.id} component="li" sx={{ mb: 0.5 }}>
<Link
component={RouterLink}
to={`/ausruestung/${item.id}`}
color="inherit"
underline="hover"
sx={{ fontWeight: 500 }}
>
{item.bezeichnung}
</Link>
{item.kategorie_kurzname ? ` (${item.kategorie_kurzname})` : ''}
{' — '}
<Typography component="span" variant="body2">
{tageText}
{item.naechste_pruefung_am
? ` (${formatDate(item.naechste_pruefung_am)})`
: ''}
</Typography>
</Box>
);
})}
</Box>
),
});
}
// 2. Important equipment not ready (affects vehicle readiness)
if (stats.wichtigNichtBereit > 0) {
groups.push({
key: 'wichtig',
severity: 'error',
title: `Wichtige Ausrüstung nicht einsatzbereit (${stats.wichtigNichtBereit})`,
content: (
<Typography variant="body2">
{stats.wichtigNichtBereit} wichtige{stats.wichtigNichtBereit === 1 ? 's' : ''}
{' '}Ausrüstungsteil{stats.wichtigNichtBereit === 1 ? '' : 'e'} nicht
einsatzbereit Fahrzeugbereitschaft kann beeinträchtigt sein.
</Typography>
),
});
}
// 3. Damaged equipment
if (stats.beschaedigt > 0) {
groups.push({
key: 'beschaedigt',
severity: 'error',
title: `${AusruestungStatusLabel[AusruestungStatus.Beschaedigt]} (${stats.beschaedigt})`,
content: (
<Typography variant="body2">
{stats.beschaedigt} Ausrüstungsteil{stats.beschaedigt === 1 ? '' : 'e'}{' '}
{stats.beschaedigt === 1 ? 'ist' : 'sind'} als{' '}
{AusruestungStatusLabel[AusruestungStatus.Beschaedigt].toLowerCase()} gemeldet.
</Typography>
),
});
}
// 4. Upcoming inspections
if (stats.inspectionsDue > 0 && upcomingItems.length > 0) {
groups.push({
key: 'upcoming',
severity: 'warning',
title: `Prüfungen fällig in den nächsten ${daysAhead} Tagen (${stats.inspectionsDue})`,
content: (
<Box component="ul" sx={{ m: 0, pl: 2 }}>
{upcomingItems.map((item) => {
const tage = item.pruefung_tage_bis_faelligkeit!;
const tageText =
tage === 0
? 'heute fällig'
: `fällig in ${tage} Tag${tage === 1 ? '' : 'en'}`;
return (
<Box key={item.id} component="li" sx={{ mb: 0.5 }}>
<Link
component={RouterLink}
to={`/ausruestung/${item.id}`}
color="inherit"
underline="hover"
sx={{ fontWeight: 500 }}
>
{item.bezeichnung}
</Link>
{item.kategorie_kurzname ? ` (${item.kategorie_kurzname})` : ''}
{' — '}
<Typography component="span" variant="body2">
{tageText}
{item.naechste_pruefung_am
? ` (${formatDate(item.naechste_pruefung_am)})`
: ''}
</Typography>
</Box>
);
})}
</Box>
),
});
}
// 5. In maintenance
if (stats.inWartung > 0) {
groups.push({
key: 'wartung',
severity: 'warning',
title: `${AusruestungStatusLabel[AusruestungStatus.InWartung]} (${stats.inWartung})`,
content: (
<Typography variant="body2">
{stats.inWartung} Ausrüstungsteil{stats.inWartung === 1 ? '' : 'e'}{' '}
{stats.inWartung === 1 ? 'befindet' : 'befinden'} sich derzeit in Wartung.
</Typography>
),
});
}
// Nothing to show
if (groups.length === 0) {
if (hideWhenEmpty) return null;
return (
<Alert severity="success">
Alle Ausrüstung ist einsatzbereit. Keine Auffälligkeiten in den nächsten{' '}
{daysAhead} Tagen.
</Alert>
);
}
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
{groups.map(({ key, severity, title, content }) => (
<Alert key={key} severity={severity} variant="outlined">
<AlertTitle sx={{ fontWeight: 600 }}>{title}</AlertTitle>
{content}
</Alert>
))}
</Box>
);
};
export default EquipmentAlerts;

View File

@@ -10,11 +10,10 @@ import {
} from '@mui/material';
import {
Dashboard as DashboardIcon,
LocalFireDepartment,
DirectionsCar,
Build,
People,
CalendarMonth as CalendarIcon,
Air,
} from '@mui/icons-material';
import { useNavigate, useLocation } from 'react-router-dom';
@@ -32,11 +31,6 @@ const navigationItems: NavigationItem[] = [
icon: <DashboardIcon />,
path: '/dashboard',
},
{
text: 'Einsätze',
icon: <LocalFireDepartment />,
path: '/einsaetze',
},
{
text: 'Fahrzeuge',
icon: <DirectionsCar />,
@@ -53,9 +47,9 @@ const navigationItems: NavigationItem[] = [
path: '/mitglieder',
},
{
text: 'Dienstkalender',
icon: <CalendarIcon />,
path: '/kalender',
text: 'Atemschutz',
icon: <Air />,
path: '/atemschutz',
},
];