add now features
This commit is contained in:
148
frontend/src/components/atemschutz/AtemschutzDashboardCard.tsx
Normal file
148
frontend/src/components/atemschutz/AtemschutzDashboardCard.tsx
Normal 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;
|
||||
251
frontend/src/components/equipment/EquipmentAlerts.tsx
Normal file
251
frontend/src/components/equipment/EquipmentAlerts.tsx
Normal 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;
|
||||
@@ -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',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user