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} />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user