From d5be68ca63c05dc3797ffc0fecbc15e1a9ce43d5 Mon Sep 17 00:00:00 2001 From: Matthias Hochmeister Date: Thu, 12 Mar 2026 10:21:26 +0100 Subject: [PATCH] resolve issues with new features --- backend/src/services/bookstack.service.ts | 2 +- .../components/admin/ServiceManagerTab.tsx | 43 ++++++++-- .../src/components/admin/SystemHealthTab.tsx | 15 +++- .../src/components/admin/UserOverviewTab.tsx | 13 ++- .../atemschutz/AtemschutzDashboardCard.tsx | 6 +- frontend/src/components/chat/ChatPanel.tsx | 62 +++++++++++-- frontend/src/components/chat/ChatRoomList.tsx | 86 +++++++++++++------ .../equipment/EquipmentDashboardCard.tsx | 6 +- frontend/src/components/shared/Header.tsx | 19 ++-- frontend/src/components/shared/Sidebar.tsx | 13 ++- .../vehicles/VehicleDashboardCard.tsx | 6 +- frontend/src/contexts/ChatContext.tsx | 4 +- frontend/src/pages/Wissen.tsx | 86 ++++++++++++++----- 13 files changed, 275 insertions(+), 86 deletions(-) diff --git a/backend/src/services/bookstack.service.ts b/backend/src/services/bookstack.service.ts index b72d5e0..c819969 100644 --- a/backend/src/services/bookstack.service.ts +++ b/backend/src/services/bookstack.service.ts @@ -91,7 +91,7 @@ async function getRecentPages(): Promise { const response = await axios.get( `${bookstack.url}/api/pages`, { - params: { sort: '-updated_at', count: 5 }, + params: { sort: '-updated_at', count: 20 }, headers: buildHeaders(), }, ); diff --git a/frontend/src/components/admin/ServiceManagerTab.tsx b/frontend/src/components/admin/ServiceManagerTab.tsx index 70f722c..30d541c 100644 --- a/frontend/src/components/admin/ServiceManagerTab.tsx +++ b/frontend/src/components/admin/ServiceManagerTab.tsx @@ -17,29 +17,38 @@ import { IconButton, Typography, CircularProgress, + Select, + MenuItem, + FormControl, + InputLabel, } from '@mui/material'; import DeleteIcon from '@mui/icons-material/Delete'; import AddIcon from '@mui/icons-material/Add'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { adminApi } from '../../services/admin'; +import { useNotification } from '../../contexts/NotificationContext'; import type { PingResult } from '../../types/admin.types'; function ServiceManagerTab() { const queryClient = useQueryClient(); + const { showError } = useNotification(); const [dialogOpen, setDialogOpen] = useState(false); const [newName, setNewName] = useState(''); const [newUrl, setNewUrl] = useState(''); + const [refreshInterval, setRefreshInterval] = useState(15000); const { data: services, isLoading: servicesLoading } = useQuery({ queryKey: ['admin', 'services'], queryFn: adminApi.getServices, - refetchInterval: 15000, + refetchInterval: refreshInterval || false, + placeholderData: (previousData: any) => previousData, }); const { data: pingResults, isLoading: pingLoading } = useQuery({ queryKey: ['admin', 'services', 'ping'], queryFn: adminApi.pingAll, - refetchInterval: 15000, + refetchInterval: refreshInterval || false, + placeholderData: (previousData: any) => previousData, }); const createMutation = useMutation({ @@ -50,6 +59,10 @@ function ServiceManagerTab() { setNewName(''); setNewUrl(''); }, + onError: (error: any) => { + const message = error?.response?.data?.message || 'Service konnte nicht erstellt werden'; + showError(message); + }, }); const deleteMutation = useMutation({ @@ -57,6 +70,9 @@ function ServiceManagerTab() { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['admin', 'services'] }); }, + onError: () => { + showError('Service konnte nicht gelöscht werden'); + }, }); const getPingForUrl = (url: string): PingResult | undefined => { @@ -102,9 +118,25 @@ function ServiceManagerTab() { Service Monitor - + + + Aktualisierung + + + + @@ -185,6 +217,7 @@ function ServiceManagerTab() { fullWidth value={newUrl} onChange={(e) => setNewUrl(e.target.value)} + helperText="Vollständige URL mit http:// oder https://" /> diff --git a/frontend/src/components/admin/SystemHealthTab.tsx b/frontend/src/components/admin/SystemHealthTab.tsx index 08ef4f3..9f4cdfe 100644 --- a/frontend/src/components/admin/SystemHealthTab.tsx +++ b/frontend/src/components/admin/SystemHealthTab.tsx @@ -19,16 +19,27 @@ function formatBytes(bytes: number): string { } function SystemHealthTab() { - const { data: health, isLoading } = useQuery({ + const { data: health, isLoading, isError } = useQuery({ queryKey: ['admin', 'system', 'health'], queryFn: adminApi.getSystemHealth, refetchInterval: 30000, }); - if (isLoading || !health) { + if (isLoading) { return ; } + if (isError || !health) { + return ( + + Systemdaten konnten nicht geladen werden. + + Bitte versuchen Sie es später erneut. + + + ); + } + const heapPercent = (health.memoryUsage.heapUsed / health.memoryUsage.heapTotal) * 100; return ( diff --git a/frontend/src/components/admin/UserOverviewTab.tsx b/frontend/src/components/admin/UserOverviewTab.tsx index 137f5af..22c8ba7 100644 --- a/frontend/src/components/admin/UserOverviewTab.tsx +++ b/frontend/src/components/admin/UserOverviewTab.tsx @@ -40,7 +40,7 @@ function UserOverviewTab() { const [sortKey, setSortKey] = useState('name'); const [sortDir, setSortDir] = useState('asc'); - const { data: users, isLoading } = useQuery({ + const { data: users, isLoading, isError } = useQuery({ queryKey: ['admin', 'users'], queryFn: adminApi.getUsers, }); @@ -82,6 +82,17 @@ function UserOverviewTab() { return ; } + if (isError) { + return ( + + Benutzerdaten konnten nicht geladen werden. + + Bitte versuchen Sie es später erneut. + + + ); + } + return ( diff --git a/frontend/src/components/atemschutz/AtemschutzDashboardCard.tsx b/frontend/src/components/atemschutz/AtemschutzDashboardCard.tsx index 92326ca..952651a 100644 --- a/frontend/src/components/atemschutz/AtemschutzDashboardCard.tsx +++ b/frontend/src/components/atemschutz/AtemschutzDashboardCard.tsx @@ -12,6 +12,7 @@ import { import { Link as RouterLink } from 'react-router-dom'; import { useQuery } from '@tanstack/react-query'; import { atemschutzApi } from '../../services/atemschutz'; +import { useCountUp } from '../../hooks/useCountUp'; import type { AtemschutzStats } from '../../types/atemschutz.types'; interface AtemschutzDashboardCardProps { @@ -26,6 +27,9 @@ const AtemschutzDashboardCard: React.FC = ({ queryFn: () => atemschutzApi.getStats(), }); + const animatedReady = useCountUp(stats?.einsatzbereit ?? 0); + const animatedTotal = useCountUp(stats?.total ?? 0); + if (isLoading) { return ( @@ -72,7 +76,7 @@ const AtemschutzDashboardCard: React.FC = ({ {/* Main metric */} - {stats.einsatzbereit}/{stats.total} + {animatedReady}/{animatedTotal} einsatzbereit diff --git a/frontend/src/components/chat/ChatPanel.tsx b/frontend/src/components/chat/ChatPanel.tsx index d67af3f..46a6c92 100644 --- a/frontend/src/components/chat/ChatPanel.tsx +++ b/frontend/src/components/chat/ChatPanel.tsx @@ -4,33 +4,81 @@ import Paper from '@mui/material/Paper'; import IconButton from '@mui/material/IconButton'; import ChatIcon from '@mui/icons-material/Chat'; import Typography from '@mui/material/Typography'; +import Avatar from '@mui/material/Avatar'; +import Badge from '@mui/material/Badge'; +import Tooltip from '@mui/material/Tooltip'; +import List from '@mui/material/List'; +import ListItem from '@mui/material/ListItem'; import { useLayout } from '../../contexts/LayoutContext'; import { ChatProvider, useChat } from '../../contexts/ChatContext'; import ChatRoomList from './ChatRoomList'; import ChatMessageView from './ChatMessageView'; +const TRANSITION = 'width 225ms cubic-bezier(0.4, 0, 0.6, 1)'; +const COLLAPSED_WIDTH = 64; +const EXPANDED_WIDTH = 360; + const ChatPanelInner: React.FC = () => { const { chatPanelOpen, setChatPanelOpen } = useLayout(); - const { selectedRoomToken, connected } = useChat(); + const { rooms, selectedRoomToken, selectRoom, connected } = useChat(); if (!chatPanelOpen) { return ( - setChatPanelOpen(true)}> + setChatPanelOpen(true)} aria-label="Chat öffnen"> + {connected && ( + + {rooms.map((room) => ( + + + { + setChatPanelOpen(true); + selectRoom(room.token); + }} + sx={{ p: 0.5 }} + > + + + {room.displayName.substring(0, 2).toUpperCase()} + + + + + + ))} + + )} ); } @@ -39,12 +87,12 @@ const ChatPanelInner: React.FC = () => { @@ -62,7 +110,7 @@ const ChatPanelInner: React.FC = () => { Chat - setChatPanelOpen(false)}> + setChatPanelOpen(false)} aria-label="Chat einklappen"> diff --git a/frontend/src/components/chat/ChatRoomList.tsx b/frontend/src/components/chat/ChatRoomList.tsx index 22540f1..32301d0 100644 --- a/frontend/src/components/chat/ChatRoomList.tsx +++ b/frontend/src/components/chat/ChatRoomList.tsx @@ -1,9 +1,12 @@ import React from 'react'; import Box from '@mui/material/Box'; import List from '@mui/material/List'; +import ListItem from '@mui/material/ListItem'; import ListItemButton from '@mui/material/ListItemButton'; +import ListItemIcon from '@mui/material/ListItemIcon'; +import ListItemText from '@mui/material/ListItemText'; +import Avatar from '@mui/material/Avatar'; import Badge from '@mui/material/Badge'; -import Typography from '@mui/material/Typography'; import { useChat } from '../../contexts/ChatContext'; const ChatRoomList: React.FC = () => { @@ -12,34 +15,65 @@ const ChatRoomList: React.FC = () => { return ( - {rooms.map((room) => ( - selectRoom(room.token)} - sx={{ py: 1, px: 1.5 }} - > - - - - {room.displayName} - - {room.unreadMessages > 0 && ( + {rooms.map((room) => { + const isSelected = room.token === selectedRoomToken; + return ( + + selectRoom(room.token)} + sx={{ + py: 1, + px: 1.5, + '&.Mui-selected': { + backgroundColor: 'primary.light', + color: 'primary.contrastText', + '&:hover': { + backgroundColor: 'primary.main', + }, + '& .MuiListItemIcon-root': { + color: 'primary.contrastText', + }, + '& .MuiListItemText-secondary': { + color: 'primary.contrastText', + opacity: 0.7, + }, + }, + }} + > + - )} - - {room.lastMessage && ( - - {room.lastMessage.author}: {room.lastMessage.text} - - )} - - - ))} + invisible={room.unreadMessages === 0} + > + + {room.displayName.substring(0, 2).toUpperCase()} + + + + + + + ); + })} ); diff --git a/frontend/src/components/equipment/EquipmentDashboardCard.tsx b/frontend/src/components/equipment/EquipmentDashboardCard.tsx index af714e5..06dba80 100644 --- a/frontend/src/components/equipment/EquipmentDashboardCard.tsx +++ b/frontend/src/components/equipment/EquipmentDashboardCard.tsx @@ -12,6 +12,7 @@ import { import { Link as RouterLink } from 'react-router-dom'; import { useQuery } from '@tanstack/react-query'; import { equipmentApi } from '../../services/equipment'; +import { useCountUp } from '../../hooks/useCountUp'; import type { EquipmentStats } from '../../types/equipment.types'; interface EquipmentDashboardCardProps { @@ -26,6 +27,9 @@ const EquipmentDashboardCard: React.FC = ({ queryFn: () => equipmentApi.getStats(), }); + const animatedReady = useCountUp(stats?.einsatzbereit ?? 0); + const animatedTotal = useCountUp(stats?.total ?? 0); + if (isLoading) { return ( @@ -72,7 +76,7 @@ const EquipmentDashboardCard: React.FC = ({ {/* Main metric */} - {stats.einsatzbereit}/{stats.total} + {animatedReady}/{animatedTotal} einsatzbereit diff --git a/frontend/src/components/shared/Header.tsx b/frontend/src/components/shared/Header.tsx index d24e813..de00c14 100644 --- a/frontend/src/components/shared/Header.tsx +++ b/frontend/src/components/shared/Header.tsx @@ -4,6 +4,7 @@ import { Toolbar, Typography, IconButton, + Button, Menu, MenuItem, Avatar, @@ -94,9 +95,9 @@ function Header({ onMenuClick }: HeaderProps) { : []; const linkLabels: Record = { - nextcloud: 'Nextcloud', - bookstack: 'BookStack', - vikunja: 'Vikunja', + nextcloud: 'Nextcloud Dateien', + bookstack: 'Wissensdatenbank', + vikunja: 'Aufgabenverwaltung', }; return ( @@ -126,18 +127,18 @@ function Header({ onMenuClick }: HeaderProps) { <> {linkEntries.length > 0 && ( <> - - } + aria-label="Externe Links" aria-controls="tools-menu" aria-haspopup="true" > - - - + Externe Links + + + + + + {navigationItems.map((item) => { const isActive = location.pathname === item.path; @@ -154,11 +158,6 @@ function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) { ); })} - - - {sidebarCollapsed ? : } - - ); diff --git a/frontend/src/components/vehicles/VehicleDashboardCard.tsx b/frontend/src/components/vehicles/VehicleDashboardCard.tsx index 8f1c9cd..b2152a3 100644 --- a/frontend/src/components/vehicles/VehicleDashboardCard.tsx +++ b/frontend/src/components/vehicles/VehicleDashboardCard.tsx @@ -13,6 +13,7 @@ import { Link as RouterLink } from 'react-router-dom'; import { useQuery } from '@tanstack/react-query'; import { vehiclesApi } from '../../services/vehicles'; import { equipmentApi } from '../../services/equipment'; +import { useCountUp } from '../../hooks/useCountUp'; import type { VehicleStats, InspectionAlert } from '../../types/vehicle.types'; import type { VehicleEquipmentWarning } from '../../types/equipment.types'; @@ -38,6 +39,9 @@ const VehicleDashboardCard: React.FC = ({ queryFn: () => equipmentApi.getVehicleWarnings(), }); + const animatedReady = useCountUp(stats?.einsatzbereit ?? 0); + const animatedTotal = useCountUp(stats?.total ?? 0); + const loading = statsLoading || alertsLoading || warningsLoading; if (loading) { @@ -89,7 +93,7 @@ const VehicleDashboardCard: React.FC = ({ {/* Main metric */} - {stats.einsatzbereit}/{stats.total} + {animatedReady}/{animatedTotal} einsatzbereit diff --git a/frontend/src/contexts/ChatContext.tsx b/frontend/src/contexts/ChatContext.tsx index 59fab83..75c8a35 100644 --- a/frontend/src/contexts/ChatContext.tsx +++ b/frontend/src/contexts/ChatContext.tsx @@ -25,8 +25,8 @@ export const ChatProvider: React.FC = ({ children }) => { const { data } = useQuery({ queryKey: ['nextcloud', 'rooms'], queryFn: () => nextcloudApi.getRooms(), - refetchInterval: chatPanelOpen ? 30000 : false, - enabled: chatPanelOpen, + refetchInterval: chatPanelOpen ? 30000 : 120000, + enabled: true, }); const rooms = data?.rooms ?? []; diff --git a/frontend/src/pages/Wissen.tsx b/frontend/src/pages/Wissen.tsx index 6c4df73..898f5b1 100644 --- a/frontend/src/pages/Wissen.tsx +++ b/frontend/src/pages/Wissen.tsx @@ -11,12 +11,18 @@ import { CircularProgress, InputAdornment, Divider, + Chip, + IconButton, + Tooltip, } from '@mui/material'; -import { Search as SearchIcon } from '@mui/icons-material'; +import { Search as SearchIcon, OpenInNew } from '@mui/icons-material'; import { useQuery } from '@tanstack/react-query'; import DOMPurify from 'dompurify'; +import { formatDistanceToNow } from 'date-fns'; +import { de } from 'date-fns/locale'; import DashboardLayout from '../components/dashboard/DashboardLayout'; import { bookstackApi } from '../services/bookstack'; +import { safeOpenUrl } from '../utils/safeOpenUrl'; import type { BookStackPage, BookStackSearchResult } from '../types/bookstack.types'; export default function Wissen() { @@ -103,6 +109,21 @@ export default function Wissen() { /> + + {isSearching ? ( + <> + + Suchergebnisse für "{debouncedSearch}" + + + + ) : ( + + Zuletzt geänderte Seiten + + )} + + {listLoading ? ( @@ -114,25 +135,32 @@ export default function Wissen() { ) : ( - {listItems.map((item) => ( - - handleSelectPage(item.id)} - > - - - - ))} + {listItems.map((item) => { + const isRecentPage = 'updated_at' in item && !isSearching; + const bookName = 'book' in item && item.book ? item.book.name : undefined; + const secondaryParts: string[] = []; + if (bookName) secondaryParts.push(bookName); + if (isRecentPage && (item as BookStackPage).updated_at) { + secondaryParts.push( + `Geändert ${formatDistanceToNow(new Date((item as BookStackPage).updated_at), { addSuffix: true, locale: de })}` + ); + } + return ( + + handleSelectPage(item.id)} + > + 0 ? secondaryParts.join(' · ') : undefined} + primaryTypographyProps={{ noWrap: true }} + secondaryTypographyProps={{ noWrap: true }} + /> + + + ); + })} )} @@ -154,9 +182,21 @@ export default function Wissen() { ) : pageQuery.data?.data ? ( - - {pageQuery.data.data.name} - + + + {pageQuery.data.data.name} + + {pageQuery.data.data.url && ( + + safeOpenUrl(pageQuery.data!.data!.url)} + > + + + + )} + {pageQuery.data.data.book && ( Buch: {pageQuery.data.data.book.name}