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',
});
}
/** Extract YYYY-MM-DD from an ISO timestamp or date string for */
function toInputDate(iso: string | null | undefined): string {
if (!iso) return '';
// Already YYYY-MM-DD
if (/^\d{4}-\d{2}-\d{2}$/.test(iso)) return iso;
// Full ISO timestamp — take the first 10 chars (YYYY-MM-DD)
return iso.substring(0, 10);
}
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 = ({ label, value, color, bgcolor }) => (
{value}
{label}
);
// ── Main Page ────────────────────────────────────────────────────────────────
function Atemschutz() {
const notification = useNotification();
// Data state
const [traeger, setTraeger] = useState([]);
const [stats, setStats] = useState(null);
const [members, setMembers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// Filter state
const [search, setSearch] = useState('');
// Dialog state
const [dialogOpen, setDialogOpen] = useState(false);
const [editingId, setEditingId] = useState(null);
const [form, setForm] = useState({ ...EMPTY_FORM });
const [dialogLoading, setDialogLoading] = useState(false);
const [dialogError, setDialogError] = useState(null);
// Delete confirmation
const [deleteId, setDeleteId] = useState(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: toInputDate(item.lehrgang_datum),
untersuchung_datum: toInputDate(item.untersuchung_datum),
untersuchung_gueltig_bis: toInputDate(item.untersuchung_gueltig_bis),
untersuchung_ergebnis: item.untersuchung_ergebnis || '',
leistungstest_datum: toInputDate(item.leistungstest_datum),
leistungstest_gueltig_bis: toInputDate(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 }));
};
/** Normalize dates before submit: ensure 4-digit years, drop invalid values */
const normalizeDate = (val: string | undefined): string | undefined => {
if (!val) return undefined;
// Match YYYY-MM-DD but with potentially short year
const m = val.match(/^(\d{1,4})-(\d{2})-(\d{2})$/);
if (!m) return undefined;
const year = m[1].padStart(4, '0');
// Reject obviously wrong years (< 1900 or > 2100)
const y = parseInt(year, 10);
if (y < 1900 || y > 2100) return undefined;
return `${year}-${m[2]}-${m[3]}`;
};
// Ref to the dialog content — used to read date inputs directly from DOM on submit
const dialogContentRef = React.useRef(null);
/** Read all date inputs from DOM at submit time — bulletproof Safari workaround */
const readDatesFromDOM = (): Record => {
const result: Record = {};
const dateFieldNames = [
'lehrgang_datum',
'untersuchung_datum',
'untersuchung_gueltig_bis',
'leistungstest_datum',
'leistungstest_gueltig_bis',
];
if (!dialogContentRef.current) {
// Fallback: read from React state
for (const name of dateFieldNames) {
result[name] = normalizeDate((form as any)[name] || undefined);
}
return result;
}
for (const name of dateFieldNames) {
const input = dialogContentRef.current.querySelector(`input[name="${name}"]`) as HTMLInputElement | null;
const domVal = input?.value || '';
const stateVal = (form as any)[name] || '';
// Use DOM value if available, otherwise fall back to React state
result[name] = normalizeDate(domVal || stateVal || undefined);
}
return result;
};
const handleSubmit = async () => {
setDialogError(null);
if (!editingId && !form.user_id) {
setDialogError('Bitte ein Mitglied auswählen.');
return;
}
setDialogLoading(true);
try {
// Read date values directly from DOM (Safari workaround)
const dates = readDatesFromDOM();
// DEBUG: remove after confirming it works
const debugInfo: Record = {};
const ref = dialogContentRef.current;
if (ref) {
for (const n of ['lehrgang_datum', 'leistungstest_gueltig_bis']) {
const el = ref.querySelector(`input[name="${n}"]`) as HTMLInputElement | null;
debugInfo[n] = el ? `found, value="${el.value}"` : 'NOT FOUND in DOM';
}
}
alert('DOM dates:\n' + JSON.stringify(dates, null, 2) + '\n\nDebug:\n' + JSON.stringify(debugInfo, null, 2));
if (editingId) {
const payload: UpdateAtemschutzPayload = {
atemschutz_lehrgang: form.atemschutz_lehrgang,
lehrgang_datum: dates.lehrgang_datum,
untersuchung_datum: dates.untersuchung_datum,
untersuchung_gueltig_bis: dates.untersuchung_gueltig_bis,
untersuchung_ergebnis: (form.untersuchung_ergebnis as UntersuchungErgebnis) || undefined,
leistungstest_datum: dates.leistungstest_datum,
leistungstest_gueltig_bis: dates.leistungstest_gueltig_bis,
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: dates.lehrgang_datum,
untersuchung_datum: dates.untersuchung_datum,
untersuchung_gueltig_bis: dates.untersuchung_gueltig_bis,
untersuchung_ergebnis: (form.untersuchung_ergebnis as UntersuchungErgebnis) || undefined,
leistungstest_datum: dates.leistungstest_datum,
leistungstest_gueltig_bis: dates.leistungstest_gueltig_bis,
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 (
{/* Header */}
Atemschutzverwaltung
{!loading && stats && (
{stats.total} Gesamt
{'·'}
{stats.einsatzbereit} Einsatzbereit
{'·'}
0 ? 'error.main' : 'text.secondary'}
fontWeight={stats.untersuchungAbgelaufen > 0 ? 600 : 400}
>
{stats.untersuchungAbgelaufen} Untersuchung abgelaufen
)}
{/* Stats cards */}
{!loading && stats && (
)}
{/* Search bar */}
setSearch(e.target.value)}
size="small"
sx={{ minWidth: 280, maxWidth: 480, width: '100%' }}
InputProps={{
startAdornment: (
),
}}
/>
{/* Loading state */}
{loading && (
)}
{/* Error state */}
{!loading && error && (
Erneut versuchen
}
>
{error}
)}
{/* Empty state */}
{!loading && !error && filtered.length === 0 && (
{traeger.length === 0
? 'Keine Atemschutzträger vorhanden'
: 'Keine Ergebnisse gefunden'}
)}
{/* Table */}
{!loading && !error && filtered.length > 0 && (
Name
Dienstgrad
Lehrgang
Untersuchung gültig bis
Leistungstest gültig bis
Status
Aktionen
{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 (
{getDisplayName(item)}
{item.user_email}
{item.dienstgrad || '—'}
{item.atemschutz_lehrgang ? (
) : (
)}
{formatDate(item.untersuchung_gueltig_bis)}
{formatDate(item.leistungstest_gueltig_bis)}
);
})}
)}
{/* FAB to create */}
{/* ── Add / Edit Dialog ───────────────────────────────────────────── */}
{/* ── Delete Confirmation Dialog ──────────────────────────────────── */}
);
}
export default Atemschutz;