diff --git a/frontend/src/components/admin/DataManagementTab.tsx b/frontend/src/components/admin/DataManagementTab.tsx index 50e8809..9b6d5c6 100644 --- a/frontend/src/components/admin/DataManagementTab.tsx +++ b/frontend/src/components/admin/DataManagementTab.tsx @@ -1,10 +1,11 @@ import { useState, useCallback } from 'react'; import { Box, Paper, Typography, TextField, Button, Alert, - CircularProgress, Divider, Autocomplete, + CircularProgress, Divider, Autocomplete, Chip, } from '@mui/material'; import DeleteSweepIcon from '@mui/icons-material/DeleteSweep'; import RestartAltIcon from '@mui/icons-material/RestartAlt'; +import GroupAddIcon from '@mui/icons-material/GroupAdd'; import { api } from '../../services/api'; import { adminApi } from '../../services/admin'; import { useNotification } from '../../contexts/NotificationContext'; @@ -60,26 +61,45 @@ export default function DataManagementTab() { queryKey: ['admin', 'users'], queryFn: adminApi.getUsers, }); - const [selectedUser, setSelectedUser] = useState(null); + const [selectedUsers, setSelectedUsers] = useState([]); + const [fdiskInput, setFdiskInput] = useState(''); const [purging, setPurging] = useState(false); const [purgeConfirmOpen, setPurgeConfirmOpen] = useState(false); + const handleAddUser = useCallback((user: UserOverview) => { + setSelectedUsers(prev => prev.some(u => u.id === user.id) ? prev : [...prev, user]); + }, []); + + const handleRemoveUser = useCallback((userId: string) => { + setSelectedUsers(prev => prev.filter(u => u.id !== userId)); + }, []); + + const handleAddAll = useCallback(() => { + setSelectedUsers(users); + }, [users]); + const handlePurge = useCallback(async () => { - if (!selectedUser) return; + if (selectedUsers.length === 0) return; setPurging(true); - try { - const result = await adminApi.purgeFdiskData(selectedUser.id); - const total = result.profileFieldsCleared + result.ausbildungen + result.befoerderungen + result.untersuchungen + result.fahrgenehmigungen; - showSuccess(`${total} FDISK-Eintraege fuer ${selectedUser.name || selectedUser.email} geloescht`); - setSelectedUser(null); - } catch (err: unknown) { - const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message || 'Fehler beim Loeschen'; - showError(msg); - } finally { - setPurging(false); - setPurgeConfirmOpen(false); + let totalDeleted = 0; + let errorCount = 0; + for (const user of selectedUsers) { + try { + const result = await adminApi.purgeFdiskData(user.id); + totalDeleted += result.profileFieldsCleared + result.ausbildungen + result.befoerderungen + result.untersuchungen + result.fahrgenehmigungen; + } catch { + errorCount++; + } } - }, [selectedUser, showSuccess, showError]); + setPurging(false); + setPurgeConfirmOpen(false); + if (errorCount === 0) { + showSuccess(`${totalDeleted} FDISK-Eintraege fuer ${selectedUsers.length} ${selectedUsers.length === 1 ? 'Benutzer' : 'Benutzer'} geloescht`); + setSelectedUsers([]); + } else { + showError(`${errorCount} von ${selectedUsers.length} Benutzern konnten nicht verarbeitet werden`); + } + }, [selectedUsers, showSuccess, showError]); const [states, setStates] = useState>(() => Object.fromEntries(SECTIONS.map(s => [s.key, { days: s.defaultDays, previewCount: null, loading: false }])) @@ -168,38 +188,73 @@ export default function DataManagementTab() { {/* FDISK data purge */} - FDISK-Daten eines Benutzers loeschen + FDISK-Daten loeschen - Loescht alle FDISK-synchronisierten Daten eines Benutzers: Profilfelder, Ausbildungen, + Loescht alle FDISK-synchronisierten Daten der ausgewaehlten Benutzer: Profilfelder, Ausbildungen, Befoerderungen, Untersuchungen und Fahrgenehmigungen. Beim naechsten FDISK-Sync werden die Daten erneut importiert. !selectedUsers.some(s => s.id === u.id))} loading={usersLoading} - value={selectedUser} - onChange={(_e, v) => setSelectedUser(v)} + value={null} + inputValue={fdiskInput} + onInputChange={(_e, val, reason) => { + if (reason === 'input') setFdiskInput(val); + }} + onChange={(_e, user) => { + if (user) { + handleAddUser(user); + setFdiskInput(''); + } + }} getOptionLabel={(u) => `${u.name || 'Kein Name'} (${u.email})`} isOptionEqualToValue={(a, b) => a.id === b.id} sx={{ minWidth: 320, flex: 1 }} renderInput={(params) => ( - + )} /> + + {selectedUsers.length > 0 && ( + <> + + {selectedUsers.map(u => ( + handleRemoveUser(u.id)} + size="small" + /> + ))} + + + + + + )} !purging && setPurgeConfirmOpen(false)} onConfirm={handlePurge} title="FDISK-Daten loeschen?" - message={selectedUser ? ( + message={selectedUsers.length > 0 ? ( <> - Alle FDISK-synchronisierten Daten fuer {selectedUser.name ?? selectedUser.email} werden geloescht: + Alle FDISK-synchronisierten Daten fuer{' '} + {selectedUsers.length === 1 + ? {selectedUsers[0].name ?? selectedUsers[0].email} + : {selectedUsers.length} Benutzer + }{' '} + werden geloescht:
  • Profilfelder (Status, Dienstgrad, Geburtsdatum, Geburtsort, Geschlecht, Beruf, Wohnort, PLZ)
  • Alle Ausbildungen, Befoerderungen, Untersuchungen und Fahrgenehmigungen
  • diff --git a/frontend/src/pages/MitgliedDetail.tsx b/frontend/src/pages/MitgliedDetail.tsx index 32f34e9..0931cc4 100644 --- a/frontend/src/pages/MitgliedDetail.tsx +++ b/frontend/src/pages/MitgliedDetail.tsx @@ -224,6 +224,7 @@ function MitgliedDetail() { const [saveError, setSaveError] = useState(null); const [editMode, setEditMode] = useState(false); const [activeTab, setActiveTab] = useState(0); + const [qualSubTab, setQualSubTab] = useState(0); // Atemschutz data for Qualifikationen tab const [atemschutz, setAtemschutz] = useState(null); @@ -579,8 +580,9 @@ function MitgliedDetail() { scrollButtons="auto" > - - + + + @@ -816,98 +818,6 @@ function MitgliedDetail() { - {/* Uniform sizing + Personal equipment (merged) */} - - - } - title="Ausrüstung & Uniform" - /> - - {editMode ? ( - - handleFieldChange('tshirt_groesse', e.target.value as TshirtGroesseEnum || undefined)} - > - - {TSHIRT_GROESSE_VALUES.map((g) => ( - {g} - ))} - - handleFieldChange('schuhgroesse', e.target.value || undefined)} - placeholder="z.B. 43" - /> - - ) : ( - <> - - - - )} - - {(hasPermission('persoenliche_ausruestung:view') || hasPermission('persoenliche_ausruestung:view_all')) && ( - <> - - - Persönliche Ausrüstung - - {personalEquipmentLoading ? ( - - ) : personalEquipment.length === 0 ? ( - - Keine persönlichen Gegenstände erfasst - - ) : ( - personalEquipment.map((item) => ( - navigate(`/persoenliche-ausruestung/${item.id}`)} - > - - {item.bezeichnung} - {item.kategorie && ( - {item.kategorie} - )} - {item.eigenschaften && item.eigenschaften.length > 0 && ( - - {item.eigenschaften.map((e) => ( - - ))} - - )} - - - - )) - )} - - )} - - - - {/* Driving licenses */} @@ -981,281 +891,401 @@ function MitgliedDetail() { - {/* ---- Tab 1: Qualifikationen ---- */} + {/* ---- Tab 1: Ausrüstung & Uniform ---- */} - {atemschutzLoading ? ( - - - - ) : atemschutz ? ( - - + + + + } + title="Ausrüstung & Uniform" + /> + + {editMode ? ( + + handleFieldChange('tshirt_groesse', e.target.value as TshirtGroesseEnum || undefined)} + > + + {TSHIRT_GROESSE_VALUES.map((g) => ( + {g} + ))} + + handleFieldChange('schuhgroesse', e.target.value || undefined)} + placeholder="z.B. 43" + /> + + ) : ( + <> + + + + )} + + + + + {(hasPermission('persoenliche_ausruestung:view') || hasPermission('persoenliche_ausruestung:view_all')) && ( + } - title="Atemschutz" - action={ - : } - label={atemschutz.einsatzbereit ? 'Einsatzbereit' : 'Nicht einsatzbereit'} - color={atemschutz.einsatzbereit ? 'success' : 'error'} - size="small" - /> - } + title="Persönliche Ausrüstung" /> - - - - - G26.3 Untersuchung - - - - } - /> - - - - - Leistungstest (Finnentest) - - - - } - /> - - - {atemschutz.bemerkung && ( - <> - - - + {personalEquipmentLoading ? ( + + ) : personalEquipment.length === 0 ? ( + + Keine persönlichen Gegenstände erfasst + + ) : ( + personalEquipment.map((item) => ( + navigate(`/persoenliche-ausruestung/${item.id}`)} + > + + {item.bezeichnung} + {item.kategorie && ( + {item.kategorie} + )} + {item.eigenschaften && item.eigenschaften.length > 0 && ( + + {item.eigenschaften.map((e) => ( + + ))} + + )} + + + + )) )} - - ) : ( - - - - - - Kein Atemschutz-Eintrag - - - Für dieses Mitglied ist kein Atemschutz-Datensatz vorhanden. - - {canWrite && ( - - )} - - - - )} - - {/* FDISK-synced sub-sections */} - - {/* Beförderungen */} - {befoerderungen.length > 0 && ( - - - } - title="Beförderungen" - /> - - {befoerderungen.map((b) => ( - - ))} - - - - )} - - {/* Ausbildungen */} - {ausbildungen.length > 0 && ( - - - } - title="Ausbildungen" - /> - - {ausbildungen.map((a) => ( - - - - {a.kursname} - - - {a.kurs_datum ? new Date(a.kurs_datum).toLocaleDateString('de-AT') : '—'} - - - - {a.kurs_kurzbezeichnung && ( - - )} - {a.erfolgscode && ( - - )} - {a.ort && ( - - {a.ort} - - )} - - {a.ablaufdatum && ( - - Gültig bis: {new Date(a.ablaufdatum).toLocaleDateString('de-AT')} - - )} - - ))} - - - - )} - - {/* Untersuchungen */} - {untersuchungen.length > 0 && ( - - - } - title="Untersuchungen" - /> - - {untersuchungen.map((u) => ( - - - - {u.art} - - - {u.datum ? new Date(u.datum).toLocaleDateString('de-AT') : '—'} - - - {u.ergebnis && ( - - {u.ergebnis} - - )} - {u.anmerkungen && ( - - {u.anmerkungen} - - )} - - ))} - - - - )} - - {/* Fahrgenehmigungen */} - {fahrgenehmigungen.length > 0 && ( - - - } - title="Gesetzliche Fahrgenehmigungen" - /> - - {fahrgenehmigungen.map((f) => ( - - - - {f.klasse} - - - {f.ausstellungsdatum ? new Date(f.ausstellungsdatum).toLocaleDateString('de-AT') : '—'} - - - {(f.behoerde || f.nummer) && ( - - {[f.behoerde, f.nummer].filter(Boolean).join(' · ')} - - )} - {f.gueltig_bis && ( - - Gültig bis: {new Date(f.gueltig_bis).toLocaleDateString('de-AT')} - - )} - - ))} - - - )} - {/* ---- Tab 2: Einsätze (placeholder) ---- */} + {/* ---- Tab 2: Qualifikationen ---- */} + + setQualSubTab(v)} + variant="scrollable" + scrollButtons="auto" + aria-label="Qualifikationen" + > + + + + + + + + + {/* Sub-tab 0: Atemschutz */} + + {atemschutzLoading ? ( + + + + ) : atemschutz ? ( + + } + title="Atemschutz" + action={ + : } + label={atemschutz.einsatzbereit ? 'Einsatzbereit' : 'Nicht einsatzbereit'} + color={atemschutz.einsatzbereit ? 'success' : 'error'} + size="small" + /> + } + /> + + + + + + G26.3 Untersuchung + + + + } + /> + + + + + Leistungstest (Finnentest) + + + + } + /> + + + {atemschutz.bemerkung && ( + <> + + + + )} + + + ) : ( + + + + + + Kein Atemschutz-Eintrag + + + Für dieses Mitglied ist kein Atemschutz-Datensatz vorhanden. + + {canWrite && ( + + )} + + + + )} + + + {/* Sub-tab 1: Ausbildungen */} + + + } + title="Ausbildungen" + /> + + {ausbildungen.length === 0 ? ( + Keine Ausbildungen eingetragen. + ) : ( + ausbildungen.map((a) => ( + + + + {a.kursname} + + + {a.kurs_datum ? new Date(a.kurs_datum).toLocaleDateString('de-AT') : '—'} + + + + {a.kurs_kurzbezeichnung && ( + + )} + {a.erfolgscode && ( + + )} + {a.ort && ( + + {a.ort} + + )} + + {a.ablaufdatum && ( + + Gültig bis: {new Date(a.ablaufdatum).toLocaleDateString('de-AT')} + + )} + + )) + )} + + + + + {/* Sub-tab 2: Untersuchungen */} + + + } + title="Untersuchungen" + /> + + {untersuchungen.length === 0 ? ( + Keine Untersuchungen eingetragen. + ) : ( + untersuchungen.map((u) => ( + + + + {u.art} + + + {u.datum ? new Date(u.datum).toLocaleDateString('de-AT') : '—'} + + + {u.ergebnis && ( + + {u.ergebnis} + + )} + {u.anmerkungen && ( + + {u.anmerkungen} + + )} + + )) + )} + + + + + {/* Sub-tab 3: Beförderungen */} + + + } + title="Beförderungen" + /> + + {befoerderungen.length === 0 ? ( + Keine Beförderungen eingetragen. + ) : ( + befoerderungen.map((b) => ( + + )) + )} + + + + + {/* Sub-tab 4: Fahrgenehmigungen */} + + + } + title="Gesetzliche Fahrgenehmigungen" + /> + + {fahrgenehmigungen.length === 0 ? ( + Keine Fahrgenehmigungen eingetragen. + ) : ( + fahrgenehmigungen.map((f) => ( + + + + {f.klasse} + + + {f.ausstellungsdatum ? new Date(f.ausstellungsdatum).toLocaleDateString('de-AT') : '—'} + + + {(f.behoerde || f.nummer) && ( + + {[f.behoerde, f.nummer].filter(Boolean).join(' · ')} + + )} + {f.gueltig_bis && ( + + Gültig bis: {new Date(f.gueltig_bis).toLocaleDateString('de-AT')} + + )} + + )) + )} + + + + + + {/* ---- Tab 3: Einsätze (placeholder) ---- */} +