842 lines
32 KiB
TypeScript
842 lines
32 KiB
TypeScript
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 ChatAwareFab from '../components/shared/ChatAwareFab';
|
|
import { atemschutzApi } from '../services/atemschutz';
|
|
import { membersService } from '../services/members';
|
|
import { useNotification } from '../contexts/NotificationContext';
|
|
import { useAuth } from '../contexts/AuthContext';
|
|
import { toGermanDate, fromGermanDate, isValidGermanDate } from '../utils/dateInput';
|
|
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();
|
|
const { user } = useAuth();
|
|
const ATEMSCHUTZ_PRIVILEGED = ['dashboard_admin', 'dashboard_kommando', 'dashboard_atemschutz', 'dashboard_moderator'];
|
|
const canViewAll = user?.groups?.some(g => ATEMSCHUTZ_PRIVILEGED.includes(g)) ?? false;
|
|
const canWrite = canViewAll;
|
|
|
|
// 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);
|
|
const [dateErrors, setDateErrors] = useState<Partial<Record<keyof AtemschutzFormState, string>>>({});
|
|
|
|
// 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);
|
|
if (canViewAll) {
|
|
const [traegerData, statsData, membersData] = await Promise.all([
|
|
atemschutzApi.getAll(),
|
|
atemschutzApi.getStats(),
|
|
membersService.getMembers({ pageSize: 500 }),
|
|
]);
|
|
setTraeger(traegerData);
|
|
setStats(statsData);
|
|
setMembers(membersData.items);
|
|
} else {
|
|
const traegerData = await atemschutzApi.getAll();
|
|
setTraeger(traegerData);
|
|
}
|
|
} catch {
|
|
setError('Atemschutzdaten konnten nicht geladen werden. Bitte versuchen Sie es erneut.');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [canViewAll]);
|
|
|
|
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();
|
|
return name.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: toGermanDate(item.lehrgang_datum),
|
|
untersuchung_datum: toGermanDate(item.untersuchung_datum),
|
|
untersuchung_gueltig_bis: toGermanDate(item.untersuchung_gueltig_bis),
|
|
untersuchung_ergebnis: item.untersuchung_ergebnis || '',
|
|
leistungstest_datum: toGermanDate(item.leistungstest_datum),
|
|
leistungstest_gueltig_bis: toGermanDate(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);
|
|
setDateErrors({});
|
|
};
|
|
|
|
const handleFormChange = (
|
|
field: keyof AtemschutzFormState,
|
|
value: string | boolean
|
|
) => {
|
|
setForm((prev) => ({ ...prev, [field]: value }));
|
|
};
|
|
|
|
/** Normalize dates before submit: parse DD.MM.YYYY → YYYY-MM-DD for API */
|
|
const normalizeDate = (val: string | undefined): string | undefined => {
|
|
if (!val) return undefined;
|
|
const iso = fromGermanDate(val);
|
|
return iso || undefined;
|
|
};
|
|
|
|
const handleSubmit = async () => {
|
|
setDialogError(null);
|
|
setDateErrors({});
|
|
|
|
if (!editingId && !form.user_id) {
|
|
setDialogError('Bitte ein Mitglied auswählen.');
|
|
return;
|
|
}
|
|
|
|
// Validate date fields
|
|
const newDateErrors: Partial<Record<keyof AtemschutzFormState, string>> = {};
|
|
const dateFields: (keyof AtemschutzFormState)[] = [
|
|
'lehrgang_datum', 'untersuchung_datum', 'untersuchung_gueltig_bis',
|
|
'leistungstest_datum', 'leistungstest_gueltig_bis',
|
|
];
|
|
for (const field of dateFields) {
|
|
const val = form[field] as string;
|
|
if (val && !isValidGermanDate(val)) {
|
|
newDateErrors[field] = 'Ungültiges Datum (Format: TT.MM.JJJJ)';
|
|
}
|
|
}
|
|
if (form.untersuchung_datum && form.untersuchung_gueltig_bis &&
|
|
isValidGermanDate(form.untersuchung_datum) && isValidGermanDate(form.untersuchung_gueltig_bis)) {
|
|
const from = new Date(fromGermanDate(form.untersuchung_datum)!);
|
|
const to = new Date(fromGermanDate(form.untersuchung_gueltig_bis)!);
|
|
if (to < from) {
|
|
newDateErrors['untersuchung_gueltig_bis'] = 'Muss nach dem Untersuchungsdatum liegen';
|
|
}
|
|
}
|
|
if (form.leistungstest_datum && form.leistungstest_gueltig_bis &&
|
|
isValidGermanDate(form.leistungstest_datum) && isValidGermanDate(form.leistungstest_gueltig_bis)) {
|
|
const from = new Date(fromGermanDate(form.leistungstest_datum)!);
|
|
const to = new Date(fromGermanDate(form.leistungstest_gueltig_bis)!);
|
|
if (to < from) {
|
|
newDateErrors['leistungstest_gueltig_bis'] = 'Muss nach dem Leistungstestdatum liegen';
|
|
}
|
|
}
|
|
if (Object.keys(newDateErrors).length > 0) {
|
|
setDateErrors(newDateErrors);
|
|
return;
|
|
}
|
|
|
|
setDialogLoading(true);
|
|
try {
|
|
if (editingId) {
|
|
const payload: UpdateAtemschutzPayload = {
|
|
atemschutz_lehrgang: form.atemschutz_lehrgang,
|
|
lehrgang_datum: normalizeDate(form.lehrgang_datum || undefined),
|
|
untersuchung_datum: normalizeDate(form.untersuchung_datum || undefined),
|
|
untersuchung_gueltig_bis: normalizeDate(form.untersuchung_gueltig_bis || undefined),
|
|
untersuchung_ergebnis: (form.untersuchung_ergebnis as UntersuchungErgebnis) || null,
|
|
leistungstest_datum: normalizeDate(form.leistungstest_datum || undefined),
|
|
leistungstest_gueltig_bis: normalizeDate(form.leistungstest_gueltig_bis || undefined),
|
|
leistungstest_bestanden: form.leistungstest_bestanden,
|
|
bemerkung: form.bemerkung || null,
|
|
};
|
|
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: normalizeDate(form.lehrgang_datum || undefined),
|
|
untersuchung_datum: normalizeDate(form.untersuchung_datum || undefined),
|
|
untersuchung_gueltig_bis: normalizeDate(form.untersuchung_gueltig_bis || undefined),
|
|
untersuchung_ergebnis: (form.untersuchung_ergebnis as UntersuchungErgebnis) || undefined,
|
|
leistungstest_datum: normalizeDate(form.leistungstest_datum || undefined),
|
|
leistungstest_gueltig_bis: normalizeDate(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 && !canViewAll && (
|
|
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
|
|
Dein persönlicher Atemschutz-Status
|
|
</Typography>
|
|
)}
|
|
{!loading && stats && canViewAll && (
|
|
<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 && canViewAll && (
|
|
<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 */}
|
|
{canViewAll && (
|
|
<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 align="center">Lehrgang</TableCell>
|
|
<TableCell>Untersuchung gültig bis</TableCell>
|
|
<TableCell>Leistungstest gültig bis</TableCell>
|
|
<TableCell align="center">Status</TableCell>
|
|
{canWrite && <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>
|
|
</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>
|
|
{canWrite && (
|
|
<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 */}
|
|
{canWrite && (
|
|
<ChatAwareFab
|
|
color="primary"
|
|
aria-label="Atemschutzträger hinzufügen"
|
|
onClick={handleOpenCreate}
|
|
>
|
|
<Add />
|
|
</ChatAwareFab>
|
|
)}
|
|
|
|
{/* ── 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"
|
|
size="small"
|
|
fullWidth
|
|
placeholder="TT.MM.JJJJ"
|
|
value={form.lehrgang_datum}
|
|
onChange={(e) => handleFormChange('lehrgang_datum', e.target.value)}
|
|
error={!!dateErrors.lehrgang_datum}
|
|
helperText={dateErrors.lehrgang_datum ?? 'Format: 01.03.2025'}
|
|
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"
|
|
size="small"
|
|
fullWidth
|
|
placeholder="TT.MM.JJJJ"
|
|
value={form.untersuchung_datum}
|
|
onChange={(e) => handleFormChange('untersuchung_datum', e.target.value)}
|
|
error={!!dateErrors.untersuchung_datum}
|
|
helperText={dateErrors.untersuchung_datum ?? 'Format: 08.02.2023'}
|
|
InputLabelProps={{ shrink: true }}
|
|
/>
|
|
</Grid>
|
|
<Grid item xs={6}>
|
|
<TextField
|
|
label="Gültig bis"
|
|
size="small"
|
|
fullWidth
|
|
placeholder="TT.MM.JJJJ"
|
|
value={form.untersuchung_gueltig_bis}
|
|
onChange={(e) => handleFormChange('untersuchung_gueltig_bis', e.target.value)}
|
|
error={!!dateErrors.untersuchung_gueltig_bis}
|
|
helperText={dateErrors.untersuchung_gueltig_bis ?? 'Format: 08.02.2028'}
|
|
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"
|
|
size="small"
|
|
fullWidth
|
|
placeholder="TT.MM.JJJJ"
|
|
value={form.leistungstest_datum}
|
|
onChange={(e) => handleFormChange('leistungstest_datum', e.target.value)}
|
|
error={!!dateErrors.leistungstest_datum}
|
|
helperText={dateErrors.leistungstest_datum ?? 'Format: 25.08.2025'}
|
|
InputLabelProps={{ shrink: true }}
|
|
/>
|
|
</Grid>
|
|
<Grid item xs={6}>
|
|
<TextField
|
|
label="Gültig bis"
|
|
size="small"
|
|
fullWidth
|
|
placeholder="TT.MM.JJJJ"
|
|
value={form.leistungstest_gueltig_bis}
|
|
onChange={(e) => handleFormChange('leistungstest_gueltig_bis', e.target.value)}
|
|
error={!!dateErrors.leistungstest_gueltig_bis}
|
|
helperText={dateErrors.leistungstest_gueltig_bis ?? 'Format: 25.08.2026'}
|
|
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;
|