diff --git a/backend/src/config/database.ts b/backend/src/config/database.ts index 230b3e5..9ce0a76 100644 --- a/backend/src/config/database.ts +++ b/backend/src/config/database.ts @@ -16,7 +16,7 @@ const poolConfig: PoolConfig = { password: environment.database.password, max: 20, // Maximum number of clients in the pool idleTimeoutMillis: 30000, // Close idle clients after 30 seconds - connectionTimeoutMillis: 2000, // Return an error if connection takes longer than 2 seconds + connectionTimeoutMillis: 5000, // Return an error if connection takes longer than 5 seconds }; const pool = new Pool(poolConfig); diff --git a/backend/src/config/environment.ts b/backend/src/config/environment.ts index 7160af4..fec1b61 100644 --- a/backend/src/config/environment.ts +++ b/backend/src/config/environment.ts @@ -54,7 +54,7 @@ const environment: EnvironmentConfig = { }, rateLimit: { windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '900000', 10), // 15 minutes - max: parseInt(process.env.RATE_LIMIT_MAX || '100', 10), + max: parseInt(process.env.RATE_LIMIT_MAX || '300', 10), }, authentik: { issuer: process.env.AUTHENTIK_ISSUER || 'https://auth.firesuite.feuerwehr-rems.at/application/o/feuerwehr-dashboard/', diff --git a/frontend/src/components/atemschutz/AtemschutzDashboardCard.tsx b/frontend/src/components/atemschutz/AtemschutzDashboardCard.tsx index 2987658..92326ca 100644 --- a/frontend/src/components/atemschutz/AtemschutzDashboardCard.tsx +++ b/frontend/src/components/atemschutz/AtemschutzDashboardCard.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React from 'react'; import { Alert, AlertTitle, @@ -10,6 +10,7 @@ import { Typography, } from '@mui/material'; import { Link as RouterLink } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; import { atemschutzApi } from '../../services/atemschutz'; import type { AtemschutzStats } from '../../types/atemschutz.types'; @@ -20,31 +21,12 @@ interface AtemschutzDashboardCardProps { const AtemschutzDashboardCard: React.FC = ({ hideWhenEmpty = false, }) => { - const [stats, setStats] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + const { data: stats, isLoading, isError } = useQuery({ + queryKey: ['atemschutz-stats'], + queryFn: () => atemschutzApi.getStats(), + }); - useEffect(() => { - let mounted = true; - const fetchStats = async () => { - try { - setLoading(true); - setError(null); - const data = await atemschutzApi.getStats(); - if (mounted) setStats(data); - } catch { - if (mounted) setError('Atemschutzstatus konnte nicht geladen werden.'); - } finally { - if (mounted) setLoading(false); - } - }; - fetchStats(); - return () => { - mounted = false; - }; - }, []); - - if (loading) { + if (isLoading) { return ( @@ -57,12 +39,12 @@ const AtemschutzDashboardCard: React.FC = ({ ); } - if (error) { + if (isError) { return ( - {error} + Atemschutzstatus konnte nicht geladen werden. @@ -71,7 +53,6 @@ const AtemschutzDashboardCard: React.FC = ({ if (!stats) return null; - // Determine if there are any concerns const hasConcerns = stats.untersuchungAbgelaufen > 0 || stats.leistungstestAbgelaufen > 0 || @@ -80,7 +61,6 @@ const AtemschutzDashboardCard: React.FC = ({ const allGood = stats.einsatzbereit === stats.total && !hasConcerns; - // If hideWhenEmpty and everything is fine, render nothing if (hideWhenEmpty && allGood) return null; return ( diff --git a/frontend/src/components/dashboard/ActivityFeed.tsx b/frontend/src/components/dashboard/ActivityFeed.tsx index 3b645c3..7be3bc6 100644 --- a/frontend/src/components/dashboard/ActivityFeed.tsx +++ b/frontend/src/components/dashboard/ActivityFeed.tsx @@ -89,7 +89,7 @@ const ActivityFeed: React.FC = () => { }; return ( - + Letzte Aktivitäten @@ -110,7 +110,7 @@ const ActivityFeed: React.FC = () => { position: 'absolute', left: 19, top: 56, - bottom: -8, + bottom: 0, width: 2, bgcolor: 'divider', } diff --git a/frontend/src/components/dashboard/PersonalWarningsBanner.tsx b/frontend/src/components/dashboard/PersonalWarningsBanner.tsx index e04b638..7f386bf 100644 --- a/frontend/src/components/dashboard/PersonalWarningsBanner.tsx +++ b/frontend/src/components/dashboard/PersonalWarningsBanner.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React from 'react'; import { Alert, AlertTitle, @@ -10,6 +10,7 @@ import { } from '@mui/material'; import { NotificationsActive as NotificationsActiveIcon } from '@mui/icons-material'; import { Link as RouterLink } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; import { atemschutzApi } from '../../services/atemschutz'; import type { User } from '../../types/auth.types'; import type { AtemschutzUebersicht } from '../../types/atemschutz.types'; @@ -74,33 +75,14 @@ interface PersonalWarningsBannerProps { } const PersonalWarningsBanner: React.FC = ({ user: _user, onWarningCount }) => { - const [record, setRecord] = useState(null); - const [loading, setLoading] = useState(true); - const [fetchError, setFetchError] = useState(null); - - useEffect(() => { - let mounted = true; - - const fetchStatus = async () => { - try { - setLoading(true); - setFetchError(null); - const data = await atemschutzApi.getMyStatus(); - if (mounted) setRecord(data); - } catch { - if (mounted) setFetchError('Persönlicher Atemschutz-Status konnte nicht geladen werden.'); - } finally { - if (mounted) setLoading(false); - } - }; - - fetchStatus(); - return () => { mounted = false; }; - }, []); + const { data: record, isLoading } = useQuery({ + queryKey: ['atemschutz-my-status'], + queryFn: () => atemschutzApi.getMyStatus(), + }); // ── Loading state ────────────────────────────────────────────────────────── - if (loading) { + if (isLoading) { return ( @@ -111,13 +93,6 @@ const PersonalWarningsBanner: React.FC = ({ user: _ ); } - // ── Fetch error ──────────────────────────────────────────────────────────── - - if (fetchError) { - // Non-critical — silently swallow so the dashboard still loads cleanly. - return null; - } - // ── No atemschutz record for this user ───────────────────────────────────── if (!record) return null; diff --git a/frontend/src/components/dashboard/UpcomingEventsWidget.tsx b/frontend/src/components/dashboard/UpcomingEventsWidget.tsx index d8f848b..1967bae 100644 --- a/frontend/src/components/dashboard/UpcomingEventsWidget.tsx +++ b/frontend/src/components/dashboard/UpcomingEventsWidget.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React from 'react'; import { Box, Card, @@ -13,6 +13,7 @@ import { } from '@mui/material'; import { CalendarMonth as CalendarMonthIcon } from '@mui/icons-material'; import { Link as RouterLink } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; import { trainingApi } from '../../services/training'; import { eventsApi } from '../../services/events'; import type { UebungListItem, UebungTyp } from '../../types/training.types'; @@ -106,52 +107,34 @@ const FETCH_LIMIT = 20; // fetch more than 5 so filtering from today leaves enou const DISPLAY_LIMIT = 5; const UpcomingEventsWidget: React.FC = () => { - const [entries, setEntries] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + const { data: trainingItems = [], isLoading: trainingLoading, isError: trainingError } = useQuery({ + queryKey: ['upcoming-trainings'], + queryFn: () => trainingApi.getUpcoming(FETCH_LIMIT), + }); - useEffect(() => { - let mounted = true; + const { data: eventItems = [], isLoading: eventsLoading, isError: eventsError } = useQuery({ + queryKey: ['upcoming-events'], + queryFn: () => eventsApi.getUpcoming(FETCH_LIMIT), + }); - const fetchData = async () => { - try { - setLoading(true); - setError(null); + const loading = trainingLoading || eventsLoading; + const error = trainingError || eventsError; - const [trainingItems, eventItems] = await Promise.all([ - trainingApi.getUpcoming(FETCH_LIMIT), - eventsApi.getUpcoming(FETCH_LIMIT), - ]); - - if (!mounted) return; - - const today = startOfToday(); - - const combined: CalendarEntry[] = [ - ...trainingItems - .filter((t) => !t.abgesagt) - .map(mapTraining), - ...eventItems - .filter((e) => !e.abgesagt) - .map(mapVeranstaltung), - ] - .filter((e) => e.date >= today) - .sort((a, b) => a.date.getTime() - b.date.getTime()) - .slice(0, DISPLAY_LIMIT); - - setEntries(combined); - } catch { - if (mounted) setError('Termine konnten nicht geladen werden.'); - } finally { - if (mounted) setLoading(false); - } - }; - - fetchData(); - return () => { - mounted = false; - }; - }, []); + const entries = React.useMemo(() => { + if (loading) return []; + const today = startOfToday(); + return [ + ...trainingItems + .filter((t) => !t.abgesagt) + .map(mapTraining), + ...eventItems + .filter((e) => !e.abgesagt) + .map(mapVeranstaltung), + ] + .filter((e) => e.date >= today) + .sort((a, b) => a.date.getTime() - b.date.getTime()) + .slice(0, DISPLAY_LIMIT); + }, [trainingItems, eventItems, loading]); // ── Loading state ───────────────────────────────────────────────────────── if (loading) { @@ -177,7 +160,7 @@ const UpcomingEventsWidget: React.FC = () => { Nächste Termine - {error} + Termine konnten nicht geladen werden. diff --git a/frontend/src/components/equipment/EquipmentDashboardCard.tsx b/frontend/src/components/equipment/EquipmentDashboardCard.tsx index 22908d0..af714e5 100644 --- a/frontend/src/components/equipment/EquipmentDashboardCard.tsx +++ b/frontend/src/components/equipment/EquipmentDashboardCard.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React from 'react'; import { Alert, AlertTitle, @@ -10,6 +10,7 @@ import { Typography, } from '@mui/material'; import { Link as RouterLink } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; import { equipmentApi } from '../../services/equipment'; import type { EquipmentStats } from '../../types/equipment.types'; @@ -20,31 +21,12 @@ interface EquipmentDashboardCardProps { const EquipmentDashboardCard: React.FC = ({ hideWhenEmpty = false, }) => { - const [stats, setStats] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + const { data: stats, isLoading, isError } = useQuery({ + queryKey: ['equipment-stats'], + queryFn: () => equipmentApi.getStats(), + }); - useEffect(() => { - let mounted = true; - const fetchStats = async () => { - try { - setLoading(true); - setError(null); - const data = await equipmentApi.getStats(); - if (mounted) setStats(data); - } catch { - if (mounted) setError('Ausrüstungsstatus konnte nicht geladen werden.'); - } finally { - if (mounted) setLoading(false); - } - }; - fetchStats(); - return () => { - mounted = false; - }; - }, []); - - if (loading) { + if (isLoading) { return ( @@ -57,12 +39,12 @@ const EquipmentDashboardCard: React.FC = ({ ); } - if (error) { + if (isError) { return ( - {error} + Ausrüstungsstatus konnte nicht geladen werden. diff --git a/frontend/src/components/vehicles/VehicleDashboardCard.tsx b/frontend/src/components/vehicles/VehicleDashboardCard.tsx index 8a75d4d..8f1c9cd 100644 --- a/frontend/src/components/vehicles/VehicleDashboardCard.tsx +++ b/frontend/src/components/vehicles/VehicleDashboardCard.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React from 'react'; import { Alert, AlertTitle, @@ -10,6 +10,7 @@ import { Typography, } from '@mui/material'; 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 type { VehicleStats, InspectionAlert } from '../../types/vehicle.types'; @@ -22,39 +23,22 @@ interface VehicleDashboardCardProps { const VehicleDashboardCard: React.FC = ({ hideWhenEmpty = false, }) => { - const [stats, setStats] = useState(null); - const [alerts, setAlerts] = useState([]); - const [equipmentWarnings, setEquipmentWarnings] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + const { data: stats, isLoading: statsLoading, isError: statsError } = useQuery({ + queryKey: ['vehicle-stats'], + queryFn: () => vehiclesApi.getStats(), + }); - useEffect(() => { - let mounted = true; - const fetchData = async () => { - try { - setLoading(true); - setError(null); - const [statsData, alertsData, warningsData] = await Promise.all([ - vehiclesApi.getStats(), - vehiclesApi.getAlerts(30), - equipmentApi.getVehicleWarnings(), - ]); - if (mounted) { - setStats(statsData); - setAlerts(alertsData); - setEquipmentWarnings(warningsData); - } - } catch { - if (mounted) setError('Fahrzeugstatus konnte nicht geladen werden.'); - } finally { - if (mounted) setLoading(false); - } - }; - fetchData(); - return () => { - mounted = false; - }; - }, []); + const { data: alerts = [], isLoading: alertsLoading } = useQuery({ + queryKey: ['vehicle-alerts'], + queryFn: () => vehiclesApi.getAlerts(30), + }); + + const { data: equipmentWarnings = [], isLoading: warningsLoading } = useQuery({ + queryKey: ['vehicle-equipment-warnings'], + queryFn: () => equipmentApi.getVehicleWarnings(), + }); + + const loading = statsLoading || alertsLoading || warningsLoading; if (loading) { return ( @@ -69,12 +53,12 @@ const VehicleDashboardCard: React.FC = ({ ); } - if (error) { + if (statsError) { return ( - {error} + Fahrzeugstatus konnte nicht geladen werden. @@ -94,7 +78,6 @@ const VehicleDashboardCard: React.FC = ({ const allGood = stats.einsatzbereit === stats.total && !hasConcerns; - // If hideWhenEmpty and everything is fine, render nothing if (hideWhenEmpty && allGood) return null; return ( diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index b31294b..75c6a1e 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -10,7 +10,9 @@ const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 5 * 60 * 1000, // 5 minutes + gcTime: 10 * 60 * 1000, // keep cache 10 minutes retry: 1, + refetchOnWindowFocus: false, // prevent refetch on every tab switch }, }, }); diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 69005a4..7ac309f 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -32,7 +32,7 @@ function Dashboard() { return ( - + {/* Welcome Message */}