This commit is contained in:
Matthias Hochmeister
2026-03-16 14:41:08 +01:00
parent 5f329bb5c1
commit 215528a521
46 changed files with 462 additions and 251 deletions

View File

@@ -41,6 +41,7 @@ import {
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';
@@ -314,11 +315,11 @@ function Atemschutz() {
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,
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 || undefined,
bemerkung: form.bemerkung || null,
};
await atemschutzApi.update(editingId, payload);
notification.showSuccess('Atemschutzträger erfolgreich aktualisiert.');
@@ -594,14 +595,13 @@ function Atemschutz() {
{/* FAB to create */}
{canWrite && (
<Fab
<ChatAwareFab
color="primary"
aria-label="Atemschutzträger hinzufügen"
sx={{ position: 'fixed', bottom: 32, right: 32 }}
onClick={handleOpenCreate}
>
<Add />
</Fab>
</ChatAwareFab>
)}
{/* ── Add / Edit Dialog ───────────────────────────────────────────── */}

View File

@@ -45,6 +45,7 @@ import {
EquipmentStats,
} from '../types/equipment.types';
import { usePermissions } from '../hooks/usePermissions';
import ChatAwareFab from '../components/shared/ChatAwareFab';
// ── Status chip config ────────────────────────────────────────────────────────
@@ -464,14 +465,13 @@ function Ausruestung() {
{/* FAB for adding new equipment */}
{canManageEquipment && (
<Fab
<ChatAwareFab
color="primary"
aria-label="Ausrüstung hinzufügen"
sx={{ position: 'fixed', bottom: 32, right: 32 }}
onClick={() => navigate('/ausruestung/neu')}
>
<Add />
</Fab>
</ChatAwareFab>
)}
</Container>
</DashboardLayout>

View File

@@ -85,27 +85,6 @@ function AusruestungForm() {
const { canManageEquipment } = usePermissions();
const isEditMode = Boolean(id);
// -- Permission guard: only authorized users may create or edit equipment ----
if (!canManageEquipment) {
return (
<DashboardLayout>
<Container maxWidth="lg">
<Box sx={{ textAlign: 'center', py: 8 }}>
<Typography variant="h5" gutterBottom>
Keine Berechtigung
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
Sie haben nicht die erforderlichen Rechte, um Ausrüstung zu bearbeiten.
</Typography>
<Button variant="contained" onClick={() => navigate('/ausruestung')}>
Zurück zur Ausrüstungsübersicht
</Button>
</Box>
</Container>
</DashboardLayout>
);
}
const [form, setForm] = useState<FormState>(EMPTY_FORM);
const [loading, setLoading] = useState(isEditMode);
const [saving, setSaving] = useState(false);
@@ -168,6 +147,27 @@ function AusruestungForm() {
if (isEditMode) fetchEquipment();
}, [isEditMode, fetchEquipment]);
// -- Permission guard: only authorized users may create or edit equipment ----
if (!canManageEquipment) {
return (
<DashboardLayout>
<Container maxWidth="lg">
<Box sx={{ textAlign: 'center', py: 8 }}>
<Typography variant="h5" gutterBottom>
Keine Berechtigung
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
Sie haben nicht die erforderlichen Rechte, um Ausrüstung zu bearbeiten.
</Typography>
<Button variant="contained" onClick={() => navigate('/ausruestung')}>
Zurück zur Ausrüstungsübersicht
</Button>
</Box>
</Container>
</DashboardLayout>
);
}
// -- Validation -------------------------------------------------------------
const validate = (): boolean => {
@@ -213,19 +213,19 @@ function AusruestungForm() {
const payload: UpdateAusruestungPayload = {
bezeichnung: form.bezeichnung.trim() || undefined,
kategorie_id: form.kategorie_id || undefined,
seriennummer: form.seriennummer.trim() || undefined,
inventarnummer: form.inventarnummer.trim() || undefined,
hersteller: form.hersteller.trim() || undefined,
baujahr: form.baujahr ? parseInt(form.baujahr, 10) : undefined,
seriennummer: form.seriennummer.trim() || null,
inventarnummer: form.inventarnummer.trim() || null,
hersteller: form.hersteller.trim() || null,
baujahr: form.baujahr ? parseInt(form.baujahr, 10) : null,
status: form.status,
status_bemerkung: form.status_bemerkung.trim() || undefined,
status_bemerkung: form.status_bemerkung.trim() || null,
ist_wichtig: form.ist_wichtig,
fahrzeug_id: form.fahrzeug_id || null,
standort: !form.fahrzeug_id ? (form.standort.trim() || 'Lager') : undefined,
pruef_intervall_monate: form.pruef_intervall_monate ? parseInt(form.pruef_intervall_monate, 10) : undefined,
letzte_pruefung_am: form.letzte_pruefung_am ? fromGermanDate(form.letzte_pruefung_am) || undefined : undefined,
naechste_pruefung_am: form.naechste_pruefung_am ? fromGermanDate(form.naechste_pruefung_am) || undefined : undefined,
bemerkung: form.bemerkung.trim() || undefined,
pruef_intervall_monate: form.pruef_intervall_monate ? parseInt(form.pruef_intervall_monate, 10) : null,
letzte_pruefung_am: form.letzte_pruefung_am ? fromGermanDate(form.letzte_pruefung_am) || null : null,
naechste_pruefung_am: form.naechste_pruefung_am ? fromGermanDate(form.naechste_pruefung_am) || null : null,
bemerkung: form.bemerkung.trim() || null,
};
await equipmentApi.update(id, payload);
navigate(`/ausruestung/${id}`);

View File

@@ -70,6 +70,8 @@ function Dashboard() {
return (
<DashboardLayout>
{/* Vikunja — Overdue Notifier (invisible, polling component — outside grid) */}
<VikunjaOverdueNotifier />
<Container maxWidth={false} disableGutters>
<Box
sx={{
@@ -97,9 +99,6 @@ function Dashboard() {
</Box>
)}
{/* Vikunja — Overdue Notifier (invisible, polling component) */}
<VikunjaOverdueNotifier />
{/* Status Group */}
<WidgetGroup title="Status" gridColumn="1 / -1">
{widgetVisible('vehicles') && (

View File

@@ -49,6 +49,7 @@ import {
EINSATZ_STATUS_LABELS,
} from '../services/incidents';
import CreateEinsatzDialog from '../components/incidents/CreateEinsatzDialog';
import { useAuth } from '../contexts/AuthContext';
// ---------------------------------------------------------------------------
// COLOUR MAP for Einsatzart chips
@@ -175,6 +176,10 @@ function StatsSummaryBar({ stats, loading }: StatsSummaryProps) {
// ---------------------------------------------------------------------------
function Einsaetze() {
const navigate = useNavigate();
const { user } = useAuth();
const canWrite = user?.groups?.some((g: string) =>
['dashboard_admin', 'dashboard_kommando', 'dashboard_gruppenfuehrer'].includes(g)
) ?? false;
// List state
const [items, setItems] = useState<EinsatzListItem[]>([]);
@@ -220,7 +225,7 @@ function Einsaetze() {
filters.dateTo = end.toISOString();
}
}
if (selectedArts.length === 1) filters.einsatzArt = selectedArts[0];
if (selectedArts.length >= 1) filters.einsatzArt = selectedArts[0];
const result = await incidentsApi.getAll(filters as Parameters<typeof incidentsApi.getAll>[0]);
setItems(result.items);
@@ -308,14 +313,16 @@ function Einsaetze() {
<Refresh />
</IconButton>
</Tooltip>
<Button
variant="contained"
color="primary"
startIcon={<AddIcon />}
onClick={() => setCreateOpen(true)}
>
Neuer Einsatz
</Button>
{canWrite && (
<Button
variant="contained"
color="primary"
startIcon={<AddIcon />}
onClick={() => setCreateOpen(true)}
>
Neuer Einsatz
</Button>
)}
</Stack>
</Box>

View File

@@ -43,6 +43,7 @@ import {
EinsatzArt,
} from '../services/incidents';
import { useNotification } from '../contexts/NotificationContext';
import { useAuth } from '../contexts/AuthContext';
// ---------------------------------------------------------------------------
// COLOUR MAPS
@@ -164,6 +165,10 @@ function EinsatzDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const notification = useNotification();
const { user } = useAuth();
const canWrite = user?.groups?.some((g: string) =>
['dashboard_admin', 'dashboard_kommando', 'dashboard_gruppenfuehrer'].includes(g)
) ?? false;
const [einsatz, setEinsatz] = useState<EinsatzDetailType | null>(null);
const [loading, setLoading] = useState(true);
@@ -297,7 +302,7 @@ function EinsatzDetail() {
PDF Export
</Button>
</Tooltip>
{!editing ? (
{canWrite && !editing ? (
<Button
variant="contained"
startIcon={<Edit />}
@@ -306,7 +311,7 @@ function EinsatzDetail() {
>
Bearbeiten
</Button>
) : (
) : canWrite && editing ? (
<>
<Button
variant="outlined"
@@ -328,7 +333,7 @@ function EinsatzDetail() {
{saving ? 'Speichere...' : 'Speichern'}
</Button>
</>
)}
) : null}
</Stack>
</Box>

View File

@@ -43,6 +43,7 @@ import {
Block,
} from '@mui/icons-material';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import ChatAwareFab from '../components/shared/ChatAwareFab';
import { useAuth } from '../contexts/AuthContext';
import { useNotification } from '../contexts/NotificationContext';
import { bookingApi, fetchVehicles } from '../services/bookings';
@@ -593,14 +594,13 @@ function FahrzeugBuchungen() {
{/* ── FAB ── */}
{canCreate && (
<Fab
<ChatAwareFab
color="primary"
aria-label="Buchung erstellen"
sx={{ position: 'fixed', bottom: 32, right: 32 }}
onClick={openCreateDialog}
>
<Add />
</Fab>
</ChatAwareFab>
)}
{/* ── Booking detail popover ── */}

View File

@@ -80,27 +80,6 @@ function FahrzeugForm() {
const { isAdmin } = usePermissions();
const isEditMode = Boolean(id);
// ── Permission guard: only admins may create or edit vehicles ──────────────
if (!isAdmin) {
return (
<DashboardLayout>
<Container maxWidth="lg">
<Box sx={{ textAlign: 'center', py: 8 }}>
<Typography variant="h5" gutterBottom>
Keine Berechtigung
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
Sie haben nicht die erforderlichen Rechte, um Fahrzeuge zu bearbeiten.
</Typography>
<Button variant="contained" onClick={() => navigate('/fahrzeuge')}>
Zurück zur Fahrzeugübersicht
</Button>
</Box>
</Container>
</DashboardLayout>
);
}
const [form, setForm] = useState<FormState>(EMPTY_FORM);
const [loading, setLoading] = useState(isEditMode);
const [saving, setSaving] = useState(false);
@@ -141,6 +120,27 @@ function FahrzeugForm() {
if (isEditMode) fetchVehicle();
}, [isEditMode, fetchVehicle]);
// ── Permission guard: only admins may create or edit vehicles ──────────────
if (!isAdmin) {
return (
<DashboardLayout>
<Container maxWidth="lg">
<Box sx={{ textAlign: 'center', py: 8 }}>
<Typography variant="h5" gutterBottom>
Keine Berechtigung
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
Sie haben nicht die erforderlichen Rechte, um Fahrzeuge zu bearbeiten.
</Typography>
<Button variant="contained" onClick={() => navigate('/fahrzeuge')}>
Zurück zur Fahrzeugübersicht
</Button>
</Box>
</Container>
</DashboardLayout>
);
}
const validate = (): boolean => {
const errors: Partial<Record<keyof FormState, string>> = {};
if (!form.bezeichnung.trim()) {
@@ -160,19 +160,19 @@ function FahrzeugForm() {
if (isEditMode && id) {
const payload: UpdateFahrzeugPayload = {
bezeichnung: form.bezeichnung.trim() || undefined,
kurzname: form.kurzname.trim() || undefined,
amtliches_kennzeichen: form.amtliches_kennzeichen.trim() || undefined,
fahrgestellnummer: form.fahrgestellnummer.trim() || undefined,
baujahr: form.baujahr ? Number(form.baujahr) : undefined,
hersteller: form.hersteller.trim() || undefined,
typ_schluessel: form.typ_schluessel.trim() || undefined,
besatzung_soll: form.besatzung_soll.trim() || undefined,
kurzname: form.kurzname.trim() || null,
amtliches_kennzeichen: form.amtliches_kennzeichen.trim() || null,
fahrgestellnummer: form.fahrgestellnummer.trim() || null,
baujahr: form.baujahr ? Number(form.baujahr) : null,
hersteller: form.hersteller.trim() || null,
typ_schluessel: form.typ_schluessel.trim() || null,
besatzung_soll: form.besatzung_soll.trim() || null,
status: form.status,
status_bemerkung: form.status_bemerkung.trim() || undefined,
status_bemerkung: form.status_bemerkung.trim() || null,
standort: form.standort.trim() || 'Feuerwehrhaus',
bild_url: form.bild_url.trim() || undefined,
paragraph57a_faellig_am: form.paragraph57a_faellig_am || undefined,
naechste_wartung_am: form.naechste_wartung_am || undefined,
bild_url: form.bild_url.trim() || null,
paragraph57a_faellig_am: form.paragraph57a_faellig_am || null,
naechste_wartung_am: form.naechste_wartung_am || null,
};
await vehiclesApi.update(id, payload);
navigate(`/fahrzeuge/${id}`);

View File

@@ -135,8 +135,13 @@ function Mitglieder() {
fetchMembers();
}, [fetchMembers, debouncedSearch]);
// Also fetch when page/filters change
// Also fetch when page/filters change (skip initial mount to avoid double-fetch)
const isInitialMount = useRef(true);
useEffect(() => {
if (isInitialMount.current) {
isInitialMount.current = false;
return;
}
fetchMembers();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [page, selectedStatus, selectedDienstgrad]);

View File

@@ -130,8 +130,8 @@ function Settings() {
const result = await nextcloudApi.poll(pollToken, pollEndpoint);
if (result.completed) {
stopPolling();
queryClient.invalidateQueries({ queryKey: ['nextcloud-talk'] });
queryClient.invalidateQueries({ queryKey: ['nextcloud-talk-rooms'] });
queryClient.invalidateQueries({ queryKey: ['nextcloud', 'connection'] });
queryClient.invalidateQueries({ queryKey: ['nextcloud', 'rooms'] });
}
} catch {
// Polling error — keep trying until timeout

View File

@@ -1101,7 +1101,8 @@ export default function Veranstaltungen() {
};
const handleToday = () => {
setViewMonth({ year: today.getFullYear(), month: today.getMonth() });
const now = new Date();
setViewMonth({ year: now.getFullYear(), month: now.getMonth() });
};
// ---------------------------------------------------------------------------

View File

@@ -457,30 +457,29 @@ const AuditLog: React.FC = () => {
setLoading(true);
setError(null);
try {
const params: Record<string, string> = {
page: String(pagination.page + 1), // convert 0-based to 1-based
pageSize: String(pagination.pageSize),
};
const params = new URLSearchParams();
params.set('page', String(pagination.page + 1));
params.set('pageSize', String(pagination.pageSize));
if (f.dateFrom) {
const iso = fromGermanDate(f.dateFrom);
if (iso) params.dateFrom = new Date(iso).toISOString();
if (iso) params.set('dateFrom', new Date(iso).toISOString());
}
if (f.dateTo) {
const iso = fromGermanDate(f.dateTo);
if (iso) params.dateTo = new Date(iso + 'T23:59:59').toISOString();
if (iso) params.set('dateTo', new Date(iso + 'T23:59:59').toISOString());
}
if (f.action && f.action.length > 0) {
params.action = f.action.join(',');
if (f.action && f.action.length > 0) {
f.action.forEach((a) => params.append('action', a));
}
if (f.resourceType && f.resourceType.length > 0) {
params.resourceType = f.resourceType.join(',');
f.resourceType.forEach((rt) => params.append('resourceType', rt));
}
if (f.userId) params.userId = f.userId;
if (f.userId) params.set('userId', f.userId);
const queryString = new URLSearchParams(params).toString();
const queryString = params.toString();
const response = await api.get<{ success: boolean; data: AuditLogPage }>(
`/admin/audit-log?${queryString}`
`/api/admin/audit-log?${queryString}`
);
setRows(response.data.data.entries);
@@ -538,7 +537,7 @@ const AuditLog: React.FC = () => {
const queryString = new URLSearchParams(params).toString();
const response = await api.get<Blob>(
`/admin/audit-log/export?${queryString}`,
`/api/admin/audit-log/export?${queryString}`,
{ responseType: 'blob' }
);