update
This commit is contained in:
@@ -18,6 +18,16 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { adminApi } from '../../services/admin';
|
||||
import type { UserOverview } from '../../types/admin.types';
|
||||
|
||||
function getRoleFromGroups(groups: string[] | null): string {
|
||||
if (!groups) return 'Mitglied';
|
||||
if (groups.includes('dashboard_admin')) return 'Admin';
|
||||
if (groups.includes('dashboard_kommando')) return 'Kommandant';
|
||||
if (groups.includes('dashboard_gruppenfuehrer')) return 'Gruppenführer';
|
||||
if (groups.includes('dashboard_moderator')) return 'Moderator';
|
||||
if (groups.includes('dashboard_atemschutz')) return 'Atemschutz';
|
||||
return 'Mitglied';
|
||||
}
|
||||
|
||||
type SortKey = 'name' | 'email' | 'role' | 'is_active' | 'last_login_at';
|
||||
type SortDir = 'asc' | 'desc';
|
||||
|
||||
@@ -145,9 +155,9 @@ function UserOverviewTab() {
|
||||
<TableCell>{user.email}</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={user.role}
|
||||
label={getRoleFromGroups(user.groups)}
|
||||
size="small"
|
||||
color={user.role === 'admin' ? 'error' : 'default'}
|
||||
color={getRoleFromGroups(user.groups) === 'Admin' ? 'error' : 'default'}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
|
||||
@@ -24,6 +24,8 @@ const ChatMessageView: React.FC = () => {
|
||||
const { chatPanelOpen } = useLayout();
|
||||
const queryClient = useQueryClient();
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const isInitialLoadRef = useRef(true);
|
||||
const [input, setInput] = useState('');
|
||||
const [messages, setMessages] = useState<NextcloudMessage[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -63,6 +65,7 @@ const ChatMessageView: React.FC = () => {
|
||||
setIsLoading(true);
|
||||
setMessages([]);
|
||||
setReactionsMap(new Map());
|
||||
isInitialLoadRef.current = true;
|
||||
lastMsgIdRef.current = 0;
|
||||
|
||||
// Step 1: Initial fetch — Nextcloud returns newest-first, so reverse for chronological display
|
||||
@@ -143,17 +146,31 @@ const ChatMessageView: React.FC = () => {
|
||||
},
|
||||
});
|
||||
|
||||
// Mark room as read while viewing messages
|
||||
// Mark room as read when first opened
|
||||
useEffect(() => {
|
||||
if (selectedRoomToken && chatPanelOpen) {
|
||||
nextcloudApi.markAsRead(selectedRoomToken).then(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['nextcloud', 'rooms'] });
|
||||
}).catch(() => {});
|
||||
}
|
||||
}, [selectedRoomToken, chatPanelOpen, queryClient, messages.length]);
|
||||
}, [selectedRoomToken, chatPanelOpen, queryClient]);
|
||||
|
||||
// Smart scroll: instant on initial load, smooth only when user is near bottom
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
if (!messagesEndRef.current) return;
|
||||
if (isInitialLoadRef.current) {
|
||||
messagesEndRef.current.scrollIntoView();
|
||||
if (messages.length > 0) isInitialLoadRef.current = false;
|
||||
return;
|
||||
}
|
||||
const container = scrollContainerRef.current;
|
||||
if (container) {
|
||||
const { scrollHeight, scrollTop, clientHeight } = container;
|
||||
const isNearBottom = scrollHeight - scrollTop - clientHeight < 150;
|
||||
if (isNearBottom) {
|
||||
messagesEndRef.current.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
const handleSend = () => {
|
||||
@@ -230,6 +247,7 @@ const ChatMessageView: React.FC = () => {
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
ref={scrollContainerRef}
|
||||
sx={{
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
|
||||
@@ -30,6 +30,7 @@ const ChatPanelInner: React.FC = () => {
|
||||
const { chatPanelOpen, setChatPanelOpen } = useLayout();
|
||||
const { rooms, selectedRoomToken, selectRoom, connected } = useChat();
|
||||
const queryClient = useQueryClient();
|
||||
const markedRoomsRef = React.useRef(new Set<string>());
|
||||
const { data: externalLinks } = useQuery({
|
||||
queryKey: ['external-links'],
|
||||
queryFn: () => configApi.getExternalLinks(),
|
||||
@@ -46,11 +47,17 @@ const ChatPanelInner: React.FC = () => {
|
||||
}).catch(() => {});
|
||||
}, [chatPanelOpen, queryClient]);
|
||||
|
||||
// Mark unread rooms as read in Nextcloud whenever panel is open and rooms update
|
||||
// Mark unread rooms as read in Nextcloud whenever panel is open
|
||||
React.useEffect(() => {
|
||||
if (!chatPanelOpen) return;
|
||||
const unread = rooms.filter((r) => r.unreadMessages > 0);
|
||||
if (!chatPanelOpen) {
|
||||
markedRoomsRef.current.clear();
|
||||
return;
|
||||
}
|
||||
const unread = rooms.filter(
|
||||
(r) => r.unreadMessages > 0 && !markedRoomsRef.current.has(r.token),
|
||||
);
|
||||
if (unread.length === 0) return;
|
||||
unread.forEach((r) => markedRoomsRef.current.add(r.token));
|
||||
Promise.allSettled(unread.map((r) => nextcloudApi.markAsRead(r.token))).then(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['nextcloud', 'rooms'] });
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
@@ -25,6 +25,14 @@ const NewChatDialog: React.FC<NewChatDialogProps> = ({ open, onClose, onRoomCrea
|
||||
const [search, setSearch] = useState('');
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
// Reset state when dialog opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSearch('');
|
||||
setCreating(false);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const { data: users, isLoading } = useQuery({
|
||||
queryKey: ['nextcloud', 'users', search],
|
||||
queryFn: () => nextcloudApi.searchUsers(search),
|
||||
|
||||
@@ -76,7 +76,7 @@ const BookStackRecentWidget: React.FC = () => {
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
const configured = data?.configured ?? true;
|
||||
const configured = data?.configured ?? false;
|
||||
const pages = (data?.data ?? []).slice(0, 5);
|
||||
|
||||
if (!configured) {
|
||||
|
||||
@@ -69,6 +69,11 @@ const BookStackSearchWidget: React.FC = () => {
|
||||
const [searching, setSearching] = useState(false);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const latestQueryRef = useRef<string>('');
|
||||
const isMountedRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
return () => { isMountedRef.current = false; };
|
||||
}, []);
|
||||
|
||||
const { data, isLoading: configLoading } = useQuery({
|
||||
queryKey: ['bookstack-recent'],
|
||||
@@ -96,15 +101,15 @@ const BookStackSearchWidget: React.FC = () => {
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
try {
|
||||
const response = await bookstackApi.search(thisQuery);
|
||||
if (latestQueryRef.current === thisQuery) {
|
||||
if (isMountedRef.current && latestQueryRef.current === thisQuery) {
|
||||
setResults(response.data);
|
||||
}
|
||||
} catch {
|
||||
if (latestQueryRef.current === thisQuery) {
|
||||
if (isMountedRef.current && latestQueryRef.current === thisQuery) {
|
||||
setResults([]);
|
||||
}
|
||||
} finally {
|
||||
if (latestQueryRef.current === thisQuery) {
|
||||
if (isMountedRef.current && latestQueryRef.current === thisQuery) {
|
||||
setSearching(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ function DashboardLayoutInner({ children }: DashboardLayoutProps) {
|
||||
}
|
||||
|
||||
const sidebarWidth = sidebarCollapsed ? DRAWER_WIDTH_COLLAPSED : DRAWER_WIDTH;
|
||||
const chatWidth = chatPanelOpen ? 360 : 60;
|
||||
const chatWidth = chatPanelOpen ? 360 : 64;
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', height: '100vh', overflow: 'hidden' }}>
|
||||
|
||||
@@ -128,14 +128,7 @@ const EventQuickAddWidget: React.FC = () => {
|
||||
<Typography variant="h6">Veranstaltung</Typography>
|
||||
</Box>
|
||||
|
||||
{false ? (
|
||||
<Box>
|
||||
<Skeleton variant="rectangular" height={40} sx={{ mb: 1.5, borderRadius: 1 }} />
|
||||
<Skeleton variant="rectangular" height={40} sx={{ mb: 1.5, borderRadius: 1 }} />
|
||||
<Skeleton variant="rectangular" height={40} sx={{ borderRadius: 1 }} />
|
||||
</Box>
|
||||
) : (
|
||||
<Box component="form" onSubmit={handleSubmit} sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
<Box component="form" onSubmit={handleSubmit} sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
@@ -207,7 +200,6 @@ const EventQuickAddWidget: React.FC = () => {
|
||||
{mutation.isPending ? 'Wird erstellt…' : 'Erstellen'}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -75,6 +75,7 @@ const VehicleBookingQuickAddWidget: React.FC = () => {
|
||||
setEnde(fresh.ende);
|
||||
setBeschreibung('');
|
||||
queryClient.invalidateQueries({ queryKey: ['bookings'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['upcoming-vehicle-bookings'] });
|
||||
},
|
||||
onError: () => {
|
||||
showError('Fahrzeugbuchung konnte nicht erstellt werden');
|
||||
|
||||
@@ -76,8 +76,13 @@ const CreateEinsatzDialog: React.FC<CreateEinsatzDialogProps> = ({
|
||||
try {
|
||||
// Convert local datetime string to UTC ISO string
|
||||
const isoLocal = fromGermanDateTime(form.alarm_time_local);
|
||||
if (!isoLocal) {
|
||||
setError('Ungültiges Datum/Uhrzeit-Format. Bitte TT.MM.JJJJ HH:MM verwenden.');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const payload: CreateEinsatzPayload = {
|
||||
alarm_time: isoLocal ? new Date(isoLocal).toISOString() : new Date().toISOString(),
|
||||
alarm_time: new Date(isoLocal).toISOString(),
|
||||
einsatz_art: form.einsatz_art,
|
||||
einsatz_stichwort: form.einsatz_stichwort || null,
|
||||
strasse: form.strasse || null,
|
||||
|
||||
@@ -91,7 +91,7 @@ class ErrorBoundary extends Component<Props, State> {
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
bgcolor: 'grey.100',
|
||||
bgcolor: 'action.hover',
|
||||
p: 2,
|
||||
borderRadius: 1,
|
||||
mb: 3,
|
||||
|
||||
@@ -26,9 +26,15 @@ import type { Notification, NotificationSchwere } from '../../types/notification
|
||||
|
||||
const POLL_INTERVAL_MS = 15_000; // 15 seconds
|
||||
|
||||
let sharedAudioCtx: AudioContext | null = null;
|
||||
|
||||
function playNotificationSound() {
|
||||
try {
|
||||
const ctx = new AudioContext();
|
||||
if (!sharedAudioCtx || sharedAudioCtx.state === 'closed') {
|
||||
sharedAudioCtx = new AudioContext();
|
||||
}
|
||||
const ctx = sharedAudioCtx;
|
||||
if (ctx.state === 'suspended') ctx.resume();
|
||||
const oscillator = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
oscillator.connect(gain);
|
||||
@@ -41,7 +47,6 @@ function playNotificationSound() {
|
||||
gain.gain.linearRampToValueAtTime(0, now + 0.15);
|
||||
oscillator.start(now);
|
||||
oscillator.stop(now + 0.15);
|
||||
oscillator.onended = () => ctx.close();
|
||||
} catch {
|
||||
// Audio blocked before first user interaction — fail silently
|
||||
}
|
||||
@@ -113,7 +118,9 @@ const NotificationBell: React.FC = () => {
|
||||
showNotificationToast(n.titel, severity);
|
||||
});
|
||||
// Also add all known IDs to avoid re-toasting on re-fetch
|
||||
data.forEach((n) => knownIdsRef.current!.add(n.id));
|
||||
// Prune to only current IDs to prevent unbounded growth
|
||||
const currentIds = new Set(data.map((n) => n.id));
|
||||
knownIdsRef.current = currentIds;
|
||||
} catch {
|
||||
// non-critical
|
||||
}
|
||||
|
||||
@@ -11,13 +11,16 @@ export default function ServiceModeGuard({ children }: Props) {
|
||||
const { user } = useAuth();
|
||||
const isAdmin = user?.groups?.includes('dashboard_admin') ?? false;
|
||||
|
||||
const { data: serviceMode } = useQuery({
|
||||
const { data: serviceMode, isLoading } = useQuery({
|
||||
queryKey: ['service-mode'],
|
||||
queryFn: configApi.getServiceMode,
|
||||
refetchInterval: 60_000,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
// Don't render children until we know the service mode status
|
||||
if (isLoading) return null;
|
||||
|
||||
if (serviceMode?.active && !isAdmin) {
|
||||
return <ServiceModePage message={serviceMode.message} />;
|
||||
}
|
||||
|
||||
@@ -139,9 +139,12 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
||||
const user = await authService.getCurrentUser();
|
||||
setUser(user);
|
||||
setState((prev) => ({ ...prev, user }));
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error('Failed to refresh user data:', error);
|
||||
logout();
|
||||
// Only logout on explicit 401 — network errors / 5xx should not destroy the session
|
||||
if (error?.response?.status === 401) {
|
||||
logout();
|
||||
}
|
||||
}
|
||||
}, [logout]);
|
||||
|
||||
|
||||
@@ -56,19 +56,43 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({ children }) => {
|
||||
const connected = data?.connected ?? false;
|
||||
const loginName = data?.loginName ?? null;
|
||||
|
||||
const prevUnreadRef = useRef<Map<string, number>>(new Map());
|
||||
const isInitializedRef = useRef(false);
|
||||
|
||||
// Reset initialization flag when disconnected
|
||||
useEffect(() => {
|
||||
if (!isConnected) {
|
||||
isInitializedRef.current = false;
|
||||
}
|
||||
}, [isConnected]);
|
||||
|
||||
// Detect new unread messages while panel is closed and show toast
|
||||
useEffect(() => {
|
||||
if (!rooms.length) return;
|
||||
const prev = prevUnreadRef.current;
|
||||
const isFirstLoad = prev.size === 0;
|
||||
|
||||
if (!isInitializedRef.current) {
|
||||
// First load (or after reconnect) — initialize without toasting
|
||||
for (const room of rooms) {
|
||||
prev.set(room.token, room.unreadMessages);
|
||||
}
|
||||
isInitializedRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
for (const room of rooms) {
|
||||
const prevCount = prev.get(room.token) ?? 0;
|
||||
if (!isFirstLoad && !chatPanelOpen && room.unreadMessages > prevCount) {
|
||||
if (!chatPanelOpen && room.unreadMessages > prevCount) {
|
||||
showNotificationToast(room.displayName, 'info');
|
||||
}
|
||||
prev.set(room.token, room.unreadMessages);
|
||||
}
|
||||
|
||||
// Prune entries for rooms no longer in the list
|
||||
const currentTokens = new Set(rooms.map((r) => r.token));
|
||||
for (const key of prev.keys()) {
|
||||
if (!currentTokens.has(key)) prev.delete(key);
|
||||
}
|
||||
}, [rooms, chatPanelOpen, showNotificationToast]);
|
||||
|
||||
const selectRoom = useCallback((token: string | null) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { createContext, useContext, useState, ReactNode, useCallback } from 'react';
|
||||
import React, { createContext, useContext, useState, useRef, ReactNode, useCallback } from 'react';
|
||||
import { Snackbar, Alert, AlertColor } from '@mui/material';
|
||||
|
||||
interface Notification {
|
||||
@@ -22,8 +22,8 @@ interface NotificationProviderProps {
|
||||
}
|
||||
|
||||
export const NotificationProvider: React.FC<NotificationProviderProps> = ({ children }) => {
|
||||
const [_notifications, setNotifications] = useState<Notification[]>([]);
|
||||
const [currentNotification, setCurrentNotification] = useState<Notification | null>(null);
|
||||
const queueRef = useRef<Notification[]>([]);
|
||||
|
||||
// Left-side toast queue for new backend notifications
|
||||
const [toastQueue, setToastQueue] = useState<Notification[]>([]);
|
||||
@@ -32,13 +32,15 @@ export const NotificationProvider: React.FC<NotificationProviderProps> = ({ chil
|
||||
const id = Date.now();
|
||||
const notification: Notification = { id, message, severity };
|
||||
|
||||
setNotifications((prev) => [...prev, notification]);
|
||||
|
||||
// If no notification is currently displayed, show this one immediately
|
||||
if (!currentNotification) {
|
||||
setCurrentNotification(notification);
|
||||
}
|
||||
}, [currentNotification]);
|
||||
// Use functional update to avoid stale closure over currentNotification
|
||||
setCurrentNotification((prev) => {
|
||||
if (prev) {
|
||||
queueRef.current.push(notification);
|
||||
return prev;
|
||||
}
|
||||
return notification;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const showSuccess = useCallback((message: string) => {
|
||||
addNotification(message, 'success');
|
||||
@@ -68,15 +70,12 @@ export const NotificationProvider: React.FC<NotificationProviderProps> = ({ chil
|
||||
|
||||
setCurrentNotification(null);
|
||||
|
||||
// Show next notification after a short delay
|
||||
// Show next queued notification after a short delay
|
||||
setTimeout(() => {
|
||||
setNotifications((prev) => {
|
||||
const remaining = prev.filter((n) => n.id !== currentNotification?.id);
|
||||
if (remaining.length > 0) {
|
||||
setCurrentNotification(remaining[0]);
|
||||
}
|
||||
return remaining;
|
||||
});
|
||||
const next = queueRef.current.shift();
|
||||
if (next) {
|
||||
setCurrentNotification(next);
|
||||
}
|
||||
}, 200);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { createContext, useContext, useState, useEffect, useMemo } from 'react';
|
||||
import React, { createContext, useContext, useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { ThemeProvider } from '@mui/material/styles';
|
||||
import { CssBaseline } from '@mui/material';
|
||||
import { lightTheme, darkTheme } from '../theme/theme';
|
||||
@@ -50,10 +50,10 @@ export const ThemeModeProvider: React.FC<{ children: React.ReactNode }> = ({ chi
|
||||
return () => mq.removeEventListener('change', handler);
|
||||
}, []);
|
||||
|
||||
const setThemeMode = (mode: ThemeMode) => {
|
||||
const setThemeMode = useCallback((mode: ThemeMode) => {
|
||||
setThemeModeState(mode);
|
||||
localStorage.setItem(STORAGE_KEY, mode);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const resolvedMode: 'light' | 'dark' =
|
||||
themeMode === 'system' ? systemPreference : themeMode;
|
||||
|
||||
@@ -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 ───────────────────────────────────────────── */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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') && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 ── */}
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() });
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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' }
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user