add now features
This commit is contained in:
777
frontend/src/pages/Atemschutz.tsx
Normal file
777
frontend/src/pages/Atemschutz.tsx
Normal file
@@ -0,0 +1,777 @@
|
||||
import React, { useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
Checkbox,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Container,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogTitle,
|
||||
Fab,
|
||||
FormControl,
|
||||
FormControlLabel,
|
||||
Grid,
|
||||
InputAdornment,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Select,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TextField,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Add,
|
||||
Check,
|
||||
Close,
|
||||
Edit,
|
||||
Delete,
|
||||
Search,
|
||||
} from '@mui/icons-material';
|
||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
import { atemschutzApi } from '../services/atemschutz';
|
||||
import { membersService } from '../services/members';
|
||||
import { useNotification } from '../contexts/NotificationContext';
|
||||
import type {
|
||||
AtemschutzUebersicht,
|
||||
AtemschutzStats,
|
||||
CreateAtemschutzPayload,
|
||||
UpdateAtemschutzPayload,
|
||||
UntersuchungErgebnis,
|
||||
} from '../types/atemschutz.types';
|
||||
import { UntersuchungErgebnisLabel } from '../types/atemschutz.types';
|
||||
import type { MemberListItem } from '../types/member.types';
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatDate(iso: string | null): string {
|
||||
if (!iso) return '—';
|
||||
return new Date(iso).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
function getDisplayName(item: AtemschutzUebersicht): string {
|
||||
if (item.user_family_name || item.user_given_name) {
|
||||
return [item.user_family_name, item.user_given_name].filter(Boolean).join(', ');
|
||||
}
|
||||
return item.user_name || item.user_email;
|
||||
}
|
||||
|
||||
type ValidityColor = 'success.main' | 'error.main' | 'warning.main' | 'text.secondary';
|
||||
|
||||
function getValidityColor(
|
||||
gueltigBis: string | null,
|
||||
tageRest: number | null,
|
||||
soonThresholdDays: number
|
||||
): ValidityColor {
|
||||
if (!gueltigBis || tageRest === null) return 'text.secondary';
|
||||
if (tageRest < 0) return 'error.main';
|
||||
if (tageRest <= soonThresholdDays) return 'warning.main';
|
||||
return 'success.main';
|
||||
}
|
||||
|
||||
// ── Initial form state ───────────────────────────────────────────────────────
|
||||
|
||||
interface AtemschutzFormState {
|
||||
user_id: string;
|
||||
atemschutz_lehrgang: boolean;
|
||||
lehrgang_datum: string;
|
||||
untersuchung_datum: string;
|
||||
untersuchung_gueltig_bis: string;
|
||||
untersuchung_ergebnis: UntersuchungErgebnis | '';
|
||||
leistungstest_datum: string;
|
||||
leistungstest_gueltig_bis: string;
|
||||
leistungstest_bestanden: boolean;
|
||||
bemerkung: string;
|
||||
}
|
||||
|
||||
const EMPTY_FORM: AtemschutzFormState = {
|
||||
user_id: '',
|
||||
atemschutz_lehrgang: false,
|
||||
lehrgang_datum: '',
|
||||
untersuchung_datum: '',
|
||||
untersuchung_gueltig_bis: '',
|
||||
untersuchung_ergebnis: '',
|
||||
leistungstest_datum: '',
|
||||
leistungstest_gueltig_bis: '',
|
||||
leistungstest_bestanden: false,
|
||||
bemerkung: '',
|
||||
};
|
||||
|
||||
// ── Stats Card ───────────────────────────────────────────────────────────────
|
||||
|
||||
interface StatCardProps {
|
||||
label: string;
|
||||
value: number;
|
||||
color?: string;
|
||||
bgcolor?: string;
|
||||
}
|
||||
|
||||
const StatCard: React.FC<StatCardProps> = ({ label, value, color, bgcolor }) => (
|
||||
<Card sx={{ bgcolor: bgcolor || 'background.paper', height: '100%' }}>
|
||||
<CardContent sx={{ textAlign: 'center', py: 2 }}>
|
||||
<Typography variant="h3" fontWeight={700} color={color || 'text.primary'}>
|
||||
{value}
|
||||
</Typography>
|
||||
<Typography variant="body2" color={color ? color : 'text.secondary'} sx={{ mt: 0.5 }}>
|
||||
{label}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
// ── Main Page ────────────────────────────────────────────────────────────────
|
||||
|
||||
function Atemschutz() {
|
||||
const notification = useNotification();
|
||||
|
||||
// Data state
|
||||
const [traeger, setTraeger] = useState<AtemschutzUebersicht[]>([]);
|
||||
const [stats, setStats] = useState<AtemschutzStats | null>(null);
|
||||
const [members, setMembers] = useState<MemberListItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Filter state
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
// Dialog state
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [form, setForm] = useState<AtemschutzFormState>({ ...EMPTY_FORM });
|
||||
const [dialogLoading, setDialogLoading] = useState(false);
|
||||
const [dialogError, setDialogError] = useState<string | null>(null);
|
||||
|
||||
// Delete confirmation
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||
|
||||
// ── Data loading ─────────────────────────────────────────────────────────
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const [traegerData, statsData, membersData] = await Promise.all([
|
||||
atemschutzApi.getAll(),
|
||||
atemschutzApi.getStats(),
|
||||
membersService.getMembers({ pageSize: 500 }),
|
||||
]);
|
||||
setTraeger(traegerData);
|
||||
setStats(statsData);
|
||||
setMembers(membersData.items);
|
||||
} catch {
|
||||
setError('Atemschutzdaten konnten nicht geladen werden. Bitte versuchen Sie es erneut.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
// ── Filtering ────────────────────────────────────────────────────────────
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!search.trim()) return traeger;
|
||||
const q = search.toLowerCase();
|
||||
return traeger.filter((item) => {
|
||||
const name = getDisplayName(item).toLowerCase();
|
||||
const email = item.user_email.toLowerCase();
|
||||
const dienstgrad = (item.dienstgrad || '').toLowerCase();
|
||||
return name.includes(q) || email.includes(q) || dienstgrad.includes(q);
|
||||
});
|
||||
}, [traeger, search]);
|
||||
|
||||
// Members who do not already have an Atemschutz record
|
||||
const availableMembers = useMemo(() => {
|
||||
const existingUserIds = new Set(traeger.map((t) => t.user_id));
|
||||
return members.filter((m) => !existingUserIds.has(m.id));
|
||||
}, [members, traeger]);
|
||||
|
||||
// ── Dialog handlers ──────────────────────────────────────────────────────
|
||||
|
||||
const handleOpenCreate = () => {
|
||||
setEditingId(null);
|
||||
setForm({ ...EMPTY_FORM });
|
||||
setDialogError(null);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenEdit = (item: AtemschutzUebersicht) => {
|
||||
setEditingId(item.id);
|
||||
setForm({
|
||||
user_id: item.user_id,
|
||||
atemschutz_lehrgang: item.atemschutz_lehrgang,
|
||||
lehrgang_datum: item.lehrgang_datum || '',
|
||||
untersuchung_datum: item.untersuchung_datum || '',
|
||||
untersuchung_gueltig_bis: item.untersuchung_gueltig_bis || '',
|
||||
untersuchung_ergebnis: item.untersuchung_ergebnis || '',
|
||||
leistungstest_datum: item.leistungstest_datum || '',
|
||||
leistungstest_gueltig_bis: item.leistungstest_gueltig_bis || '',
|
||||
leistungstest_bestanden: item.leistungstest_bestanden || false,
|
||||
bemerkung: item.bemerkung || '',
|
||||
});
|
||||
setDialogError(null);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDialogClose = () => {
|
||||
setDialogOpen(false);
|
||||
setEditingId(null);
|
||||
setForm({ ...EMPTY_FORM });
|
||||
setDialogError(null);
|
||||
};
|
||||
|
||||
const handleFormChange = (
|
||||
field: keyof AtemschutzFormState,
|
||||
value: string | boolean
|
||||
) => {
|
||||
setForm((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setDialogError(null);
|
||||
|
||||
if (!editingId && !form.user_id) {
|
||||
setDialogError('Bitte ein Mitglied auswählen.');
|
||||
return;
|
||||
}
|
||||
|
||||
setDialogLoading(true);
|
||||
try {
|
||||
if (editingId) {
|
||||
const payload: UpdateAtemschutzPayload = {
|
||||
atemschutz_lehrgang: form.atemschutz_lehrgang,
|
||||
lehrgang_datum: form.lehrgang_datum || undefined,
|
||||
untersuchung_datum: form.untersuchung_datum || undefined,
|
||||
untersuchung_gueltig_bis: form.untersuchung_gueltig_bis || undefined,
|
||||
untersuchung_ergebnis: (form.untersuchung_ergebnis as UntersuchungErgebnis) || undefined,
|
||||
leistungstest_datum: form.leistungstest_datum || undefined,
|
||||
leistungstest_gueltig_bis: form.leistungstest_gueltig_bis || undefined,
|
||||
leistungstest_bestanden: form.leistungstest_bestanden,
|
||||
bemerkung: form.bemerkung || undefined,
|
||||
};
|
||||
await atemschutzApi.update(editingId, payload);
|
||||
notification.showSuccess('Atemschutzträger erfolgreich aktualisiert.');
|
||||
} else {
|
||||
const payload: CreateAtemschutzPayload = {
|
||||
user_id: form.user_id,
|
||||
atemschutz_lehrgang: form.atemschutz_lehrgang,
|
||||
lehrgang_datum: form.lehrgang_datum || undefined,
|
||||
untersuchung_datum: form.untersuchung_datum || undefined,
|
||||
untersuchung_gueltig_bis: form.untersuchung_gueltig_bis || undefined,
|
||||
untersuchung_ergebnis: (form.untersuchung_ergebnis as UntersuchungErgebnis) || undefined,
|
||||
leistungstest_datum: form.leistungstest_datum || undefined,
|
||||
leistungstest_gueltig_bis: form.leistungstest_gueltig_bis || undefined,
|
||||
leistungstest_bestanden: form.leistungstest_bestanden,
|
||||
bemerkung: form.bemerkung || undefined,
|
||||
};
|
||||
await atemschutzApi.create(payload);
|
||||
notification.showSuccess('Atemschutzträger erfolgreich angelegt.');
|
||||
}
|
||||
handleDialogClose();
|
||||
fetchData();
|
||||
} catch (err: any) {
|
||||
const msg = err?.message || 'Ein Fehler ist aufgetreten.';
|
||||
setDialogError(msg);
|
||||
notification.showError(msg);
|
||||
} finally {
|
||||
setDialogLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Delete handlers ──────────────────────────────────────────────────────
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!deleteId) return;
|
||||
setDeleteLoading(true);
|
||||
try {
|
||||
await atemschutzApi.delete(deleteId);
|
||||
notification.showSuccess('Atemschutzträger erfolgreich gelöscht.');
|
||||
setDeleteId(null);
|
||||
fetchData();
|
||||
} catch (err: any) {
|
||||
notification.showError(err?.message || 'Löschen fehlgeschlagen.');
|
||||
} finally {
|
||||
setDeleteLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Render ───────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<Container maxWidth="lg">
|
||||
{/* Header */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom sx={{ mb: 0 }}>
|
||||
Atemschutzverwaltung
|
||||
</Typography>
|
||||
{!loading && stats && (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mt: 0.5 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{stats.total} Gesamt
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">{'·'}</Typography>
|
||||
<Typography variant="body2" color="success.main" fontWeight={600}>
|
||||
{stats.einsatzbereit} Einsatzbereit
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">{'·'}</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color={stats.untersuchungAbgelaufen > 0 ? 'error.main' : 'text.secondary'}
|
||||
fontWeight={stats.untersuchungAbgelaufen > 0 ? 600 : 400}
|
||||
>
|
||||
{stats.untersuchungAbgelaufen} Untersuchung abgelaufen
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Stats cards */}
|
||||
{!loading && stats && (
|
||||
<Grid container spacing={2} sx={{ mb: 3 }}>
|
||||
<Grid item xs={6} sm={3}>
|
||||
<StatCard
|
||||
label="Einsatzbereit"
|
||||
value={stats.einsatzbereit}
|
||||
color="#fff"
|
||||
bgcolor="success.main"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={6} sm={3}>
|
||||
<StatCard label="Lehrgang absolviert" value={stats.mitLehrgang} />
|
||||
</Grid>
|
||||
<Grid item xs={6} sm={3}>
|
||||
<StatCard label="Untersuchung gültig" value={stats.untersuchungGueltig} />
|
||||
</Grid>
|
||||
<Grid item xs={6} sm={3}>
|
||||
<StatCard label="Leistungstest gültig" value={stats.leistungstestGueltig} />
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{/* Search bar */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<TextField
|
||||
placeholder="Suchen (Name, E-Mail, Dienstgrad...)"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
size="small"
|
||||
sx={{ minWidth: 280, maxWidth: 480, width: '100%' }}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<Search />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Loading state */}
|
||||
{loading && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Error state */}
|
||||
{!loading && error && (
|
||||
<Alert
|
||||
severity="error"
|
||||
sx={{ mb: 2 }}
|
||||
action={
|
||||
<Button color="inherit" size="small" onClick={fetchData}>
|
||||
Erneut versuchen
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!loading && !error && filtered.length === 0 && (
|
||||
<Box sx={{ textAlign: 'center', py: 8 }}>
|
||||
<Typography variant="h6" color="text.secondary">
|
||||
{traeger.length === 0
|
||||
? 'Keine Atemschutzträger vorhanden'
|
||||
: 'Keine Ergebnisse gefunden'}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
{!loading && !error && filtered.length > 0 && (
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Name</TableCell>
|
||||
<TableCell>Dienstgrad</TableCell>
|
||||
<TableCell align="center">Lehrgang</TableCell>
|
||||
<TableCell>Untersuchung gültig bis</TableCell>
|
||||
<TableCell>Leistungstest gültig bis</TableCell>
|
||||
<TableCell align="center">Status</TableCell>
|
||||
<TableCell align="right">Aktionen</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{filtered.map((item) => {
|
||||
const untersuchungColor = getValidityColor(
|
||||
item.untersuchung_gueltig_bis,
|
||||
item.untersuchung_tage_rest,
|
||||
90
|
||||
);
|
||||
const leistungstestColor = getValidityColor(
|
||||
item.leistungstest_gueltig_bis,
|
||||
item.leistungstest_tage_rest,
|
||||
30
|
||||
);
|
||||
|
||||
return (
|
||||
<TableRow key={item.id} hover>
|
||||
<TableCell>
|
||||
<Typography variant="body2" fontWeight={500}>
|
||||
{getDisplayName(item)}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{item.user_email}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2">
|
||||
{item.dienstgrad || '—'}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
{item.atemschutz_lehrgang ? (
|
||||
<Tooltip title={item.lehrgang_datum ? `Lehrgang am ${formatDate(item.lehrgang_datum)}` : 'Lehrgang absolviert'}>
|
||||
<Check color="success" fontSize="small" />
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Close color="disabled" fontSize="small" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Tooltip
|
||||
title={
|
||||
item.untersuchung_tage_rest !== null
|
||||
? item.untersuchung_tage_rest < 0
|
||||
? `Seit ${Math.abs(item.untersuchung_tage_rest)} Tagen abgelaufen`
|
||||
: `Noch ${item.untersuchung_tage_rest} Tage gültig`
|
||||
: 'Keine Untersuchung eingetragen'
|
||||
}
|
||||
>
|
||||
<Typography variant="body2" color={untersuchungColor} fontWeight={500}>
|
||||
{formatDate(item.untersuchung_gueltig_bis)}
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Tooltip
|
||||
title={
|
||||
item.leistungstest_tage_rest !== null
|
||||
? item.leistungstest_tage_rest < 0
|
||||
? `Seit ${Math.abs(item.leistungstest_tage_rest)} Tagen abgelaufen`
|
||||
: `Noch ${item.leistungstest_tage_rest} Tage gültig`
|
||||
: 'Kein Leistungstest eingetragen'
|
||||
}
|
||||
>
|
||||
<Typography variant="body2" color={leistungstestColor} fontWeight={500}>
|
||||
{formatDate(item.leistungstest_gueltig_bis)}
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<Chip
|
||||
label={item.einsatzbereit ? 'Einsatzbereit' : 'Nicht einsatzbereit'}
|
||||
color={item.einsatzbereit ? 'success' : 'error'}
|
||||
size="small"
|
||||
variant="filled"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Tooltip title="Bearbeiten">
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => handleOpenEdit(item)}
|
||||
sx={{ minWidth: 'auto', mr: 0.5 }}
|
||||
>
|
||||
<Edit fontSize="small" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip title="Löschen">
|
||||
<Button
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => setDeleteId(item.id)}
|
||||
sx={{ minWidth: 'auto' }}
|
||||
>
|
||||
<Delete fontSize="small" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
|
||||
{/* FAB to create */}
|
||||
<Fab
|
||||
color="primary"
|
||||
aria-label="Atemschutzträger hinzufügen"
|
||||
sx={{ position: 'fixed', bottom: 32, right: 32 }}
|
||||
onClick={handleOpenCreate}
|
||||
>
|
||||
<Add />
|
||||
</Fab>
|
||||
|
||||
{/* ── Add / Edit Dialog ───────────────────────────────────────────── */}
|
||||
<Dialog
|
||||
open={dialogOpen}
|
||||
onClose={handleDialogClose}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>
|
||||
{editingId ? 'Atemschutzträger bearbeiten' : 'Neuen Atemschutzträger anlegen'}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
{dialogError && (
|
||||
<Alert severity="error" sx={{ mb: 2, mt: 1 }}>
|
||||
{dialogError}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Grid container spacing={2} sx={{ mt: 0.5 }}>
|
||||
{/* User selection (only when creating) */}
|
||||
{!editingId && (
|
||||
<Grid item xs={12}>
|
||||
<FormControl fullWidth size="small" required>
|
||||
<InputLabel>Mitglied</InputLabel>
|
||||
<Select
|
||||
value={form.user_id}
|
||||
label="Mitglied"
|
||||
onChange={(e) => handleFormChange('user_id', e.target.value)}
|
||||
>
|
||||
{availableMembers.map((m) => {
|
||||
const displayName = [m.family_name, m.given_name]
|
||||
.filter(Boolean)
|
||||
.join(', ') || m.name || m.email;
|
||||
return (
|
||||
<MenuItem key={m.id} value={m.id}>
|
||||
{displayName}
|
||||
{m.dienstgrad ? ` (${m.dienstgrad})` : ''}
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
{availableMembers.length === 0 && (
|
||||
<MenuItem disabled value="">
|
||||
Keine verfügbaren Mitglieder
|
||||
</MenuItem>
|
||||
)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{/* Lehrgang */}
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="subtitle2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
Lehrgang
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={form.atemschutz_lehrgang}
|
||||
onChange={(e) => handleFormChange('atemschutz_lehrgang', e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label="Lehrgang absolviert"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<TextField
|
||||
label="Lehrgang Datum"
|
||||
type="date"
|
||||
size="small"
|
||||
fullWidth
|
||||
value={form.lehrgang_datum}
|
||||
onChange={(e) => handleFormChange('lehrgang_datum', e.target.value)}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* Untersuchung */}
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="subtitle2" color="text.secondary" sx={{ mb: 1, mt: 1 }}>
|
||||
Untersuchung
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<TextField
|
||||
label="Untersuchung Datum"
|
||||
type="date"
|
||||
size="small"
|
||||
fullWidth
|
||||
value={form.untersuchung_datum}
|
||||
onChange={(e) => handleFormChange('untersuchung_datum', e.target.value)}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<TextField
|
||||
label="Gültig bis"
|
||||
type="date"
|
||||
size="small"
|
||||
fullWidth
|
||||
value={form.untersuchung_gueltig_bis}
|
||||
onChange={(e) => handleFormChange('untersuchung_gueltig_bis', e.target.value)}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel>Ergebnis</InputLabel>
|
||||
<Select
|
||||
value={form.untersuchung_ergebnis}
|
||||
label="Ergebnis"
|
||||
onChange={(e) => handleFormChange('untersuchung_ergebnis', e.target.value)}
|
||||
>
|
||||
<MenuItem value="">— Nicht angegeben —</MenuItem>
|
||||
{(Object.keys(UntersuchungErgebnisLabel) as UntersuchungErgebnis[]).map(
|
||||
(key) => (
|
||||
<MenuItem key={key} value={key}>
|
||||
{UntersuchungErgebnisLabel[key]}
|
||||
</MenuItem>
|
||||
)
|
||||
)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
{/* Leistungstest */}
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="subtitle2" color="text.secondary" sx={{ mb: 1, mt: 1 }}>
|
||||
Leistungstest
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<TextField
|
||||
label="Leistungstest Datum"
|
||||
type="date"
|
||||
size="small"
|
||||
fullWidth
|
||||
value={form.leistungstest_datum}
|
||||
onChange={(e) => handleFormChange('leistungstest_datum', e.target.value)}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<TextField
|
||||
label="Gültig bis"
|
||||
type="date"
|
||||
size="small"
|
||||
fullWidth
|
||||
value={form.leistungstest_gueltig_bis}
|
||||
onChange={(e) => handleFormChange('leistungstest_gueltig_bis', e.target.value)}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={form.leistungstest_bestanden}
|
||||
onChange={(e) => handleFormChange('leistungstest_bestanden', e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label="Leistungstest bestanden"
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* Bemerkung */}
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
label="Bemerkung"
|
||||
multiline
|
||||
rows={3}
|
||||
size="small"
|
||||
fullWidth
|
||||
value={form.bemerkung}
|
||||
onChange={(e) => handleFormChange('bemerkung', e.target.value)}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleDialogClose} disabled={dialogLoading}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleSubmit}
|
||||
disabled={dialogLoading}
|
||||
startIcon={dialogLoading ? <CircularProgress size={16} /> : undefined}
|
||||
>
|
||||
{editingId ? 'Speichern' : 'Anlegen'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* ── Delete Confirmation Dialog ──────────────────────────────────── */}
|
||||
<Dialog open={deleteId !== null} onClose={() => setDeleteId(null)}>
|
||||
<DialogTitle>Atemschutzträger löschen</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
Soll dieser Atemschutzträger-Eintrag wirklich gelöscht werden? Diese Aktion kann
|
||||
nicht rückgängig gemacht werden.
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDeleteId(null)} disabled={deleteLoading}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
color="error"
|
||||
variant="contained"
|
||||
onClick={handleDeleteConfirm}
|
||||
disabled={deleteLoading}
|
||||
startIcon={deleteLoading ? <CircularProgress size={16} /> : undefined}
|
||||
>
|
||||
Löschen
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Container>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default Atemschutz;
|
||||
@@ -7,9 +7,6 @@ import {
|
||||
Fade,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
People,
|
||||
Warning,
|
||||
EventNote,
|
||||
DirectionsCar,
|
||||
} from '@mui/icons-material';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
@@ -23,13 +20,18 @@ import NextcloudTalkWidget from '../components/dashboard/NextcloudTalkWidget';
|
||||
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 { 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(() => {
|
||||
@@ -43,6 +45,13 @@ function Dashboard() {
|
||||
// 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);
|
||||
}, []);
|
||||
|
||||
@@ -80,62 +89,12 @@ function Dashboard() {
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{/* Stats Cards Row */}
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
{dataLoading ? (
|
||||
<SkeletonCard variant="basic" />
|
||||
) : (
|
||||
<Fade in={true} timeout={600} style={{ transitionDelay: '200ms' }}>
|
||||
<Box>
|
||||
<StatsCard
|
||||
title="Aktive Mitglieder"
|
||||
value="24"
|
||||
icon={People}
|
||||
color="primary.main"
|
||||
/>
|
||||
</Box>
|
||||
</Fade>
|
||||
)}
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
{dataLoading ? (
|
||||
<SkeletonCard variant="basic" />
|
||||
) : (
|
||||
<Fade in={true} timeout={600} style={{ transitionDelay: '250ms' }}>
|
||||
<Box>
|
||||
<StatsCard
|
||||
title="Einsätze (Jahr)"
|
||||
value="18"
|
||||
icon={Warning}
|
||||
color="error.main"
|
||||
/>
|
||||
</Box>
|
||||
</Fade>
|
||||
)}
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
{dataLoading ? (
|
||||
<SkeletonCard variant="basic" />
|
||||
) : (
|
||||
<Fade in={true} timeout={600} style={{ transitionDelay: '300ms' }}>
|
||||
<Box>
|
||||
<StatsCard
|
||||
title="Offene Aufgaben"
|
||||
value="7"
|
||||
icon={EventNote}
|
||||
color="warning.main"
|
||||
/>
|
||||
</Box>
|
||||
</Fade>
|
||||
)}
|
||||
</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: '350ms' }}>
|
||||
<Fade in={true} timeout={600} style={{ transitionDelay: '200ms' }}>
|
||||
<Box>
|
||||
<StatsCard
|
||||
title="Fahrzeuge einsatzbereit"
|
||||
@@ -147,6 +106,11 @@ function Dashboard() {
|
||||
icon={DirectionsCar}
|
||||
color="success.main"
|
||||
/>
|
||||
{vehicleWarnings.length > 0 && (
|
||||
<Typography variant="caption" color="warning.main" sx={{ mt: 0.5, display: 'block', textAlign: 'center' }}>
|
||||
{new Set(vehicleWarnings.map(w => w.fahrzeug_id)).size} Fzg. mit Ausrüstungsmangel
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Fade>
|
||||
)}
|
||||
@@ -161,6 +125,24 @@ function Dashboard() {
|
||||
</Fade>
|
||||
</Grid>
|
||||
|
||||
{/* Equipment Alerts Panel */}
|
||||
<Grid item xs={12}>
|
||||
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '400ms' }}>
|
||||
<Box>
|
||||
<EquipmentAlerts daysAhead={30} hideWhenEmpty={true} />
|
||||
</Box>
|
||||
</Fade>
|
||||
</Grid>
|
||||
|
||||
{/* Atemschutz Status Card */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '420ms' }}>
|
||||
<Box>
|
||||
<AtemschutzDashboardCard />
|
||||
</Box>
|
||||
</Fade>
|
||||
</Grid>
|
||||
|
||||
{/* Service Integration Cards */}
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="h6" gutterBottom sx={{ mt: 2 }}>
|
||||
|
||||
Reference in New Issue
Block a user