inital
This commit is contained in:
90
frontend/src/App.tsx
Normal file
90
frontend/src/App.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
import { NotificationProvider } from './contexts/NotificationContext';
|
||||
import { AuthProvider } from './contexts/AuthContext';
|
||||
import ErrorBoundary from './components/shared/ErrorBoundary';
|
||||
import ProtectedRoute from './components/auth/ProtectedRoute';
|
||||
import LoginCallback from './components/auth/LoginCallback';
|
||||
import Login from './pages/Login';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import Profile from './pages/Profile';
|
||||
import Settings from './pages/Settings';
|
||||
import Einsaetze from './pages/Einsaetze';
|
||||
import Fahrzeuge from './pages/Fahrzeuge';
|
||||
import Ausruestung from './pages/Ausruestung';
|
||||
import Mitglieder from './pages/Mitglieder';
|
||||
import NotFound from './pages/NotFound';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<NotificationProvider>
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
<Route path="/" element={<Login />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/auth/callback" element={<LoginCallback />} />
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Dashboard />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/profile"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Profile />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Settings />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/einsaetze"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Einsaetze />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/fahrzeuge"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Fahrzeuge />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/ausruestung"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Ausruestung />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/mitglieder"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Mitglieder />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
</NotificationProvider>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
95
frontend/src/components/auth/LoginCallback.tsx
Normal file
95
frontend/src/components/auth/LoginCallback.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { Box, CircularProgress, Typography, Alert, Button } from '@mui/material';
|
||||
|
||||
const LoginCallback: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { login } = useAuth();
|
||||
const [error, setError] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
const handleCallback = async () => {
|
||||
const code = searchParams.get('code');
|
||||
const errorParam = searchParams.get('error');
|
||||
|
||||
if (errorParam) {
|
||||
setError(`Authentifizierungsfehler: ${errorParam}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
setError('Kein Autorisierungscode erhalten');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await login(code);
|
||||
// Redirect to dashboard on success
|
||||
navigate('/dashboard', { replace: true });
|
||||
} catch (err) {
|
||||
console.error('Login callback error:', err);
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: 'Anmeldung fehlgeschlagen. Bitte versuchen Sie es erneut.'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
handleCallback();
|
||||
}, [searchParams, login, navigate]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: '100vh',
|
||||
padding: 3,
|
||||
}}
|
||||
>
|
||||
<Alert
|
||||
severity="error"
|
||||
sx={{
|
||||
maxWidth: 500,
|
||||
mb: 2,
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</Alert>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => navigate('/login')}
|
||||
>
|
||||
Zurück zur Anmeldung
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: '100vh',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<CircularProgress size={60} />
|
||||
<Typography variant="h6" color="text.secondary">
|
||||
Anmeldung wird abgeschlossen...
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginCallback;
|
||||
43
frontend/src/components/auth/ProtectedRoute.tsx
Normal file
43
frontend/src/components/auth/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { Box, CircularProgress, Typography } from '@mui/material';
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
|
||||
// Show loading spinner while checking authentication
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: '100vh',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<CircularProgress size={60} />
|
||||
<Typography variant="h6" color="text.secondary">
|
||||
Authentifizierung wird überprüft...
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// If not authenticated, redirect to login
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
// User is authenticated, render children
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export default ProtectedRoute;
|
||||
174
frontend/src/components/dashboard/ActivityFeed.tsx
Normal file
174
frontend/src/components/dashboard/ActivityFeed.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemAvatar,
|
||||
ListItemText,
|
||||
Avatar,
|
||||
Box,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
LocalFireDepartment,
|
||||
Person,
|
||||
DirectionsCar,
|
||||
Assignment,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
interface Activity {
|
||||
id: string;
|
||||
type: 'incident' | 'member' | 'vehicle' | 'task';
|
||||
title: string;
|
||||
description: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
// Placeholder activities
|
||||
const placeholderActivities: Activity[] = [
|
||||
{
|
||||
id: '1',
|
||||
type: 'incident',
|
||||
title: 'Brandeinsatz',
|
||||
description: 'Kleinbrand in der Hauptstraße',
|
||||
timestamp: 'Vor 2 Stunden',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'member',
|
||||
title: 'Neues Mitglied',
|
||||
description: 'Max Mustermann ist der Feuerwehr beigetreten',
|
||||
timestamp: 'Vor 5 Stunden',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
type: 'vehicle',
|
||||
title: 'Fahrzeugwartung',
|
||||
description: 'LF 16/12 - Wartung abgeschlossen',
|
||||
timestamp: 'Gestern',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
type: 'task',
|
||||
title: 'Aufgabe zugewiesen',
|
||||
description: 'Neue Aufgabe: Inventur Atemschutzgeräte',
|
||||
timestamp: 'Vor 2 Tagen',
|
||||
},
|
||||
];
|
||||
|
||||
const ActivityFeed: React.FC = () => {
|
||||
const getActivityIcon = (type: Activity['type']) => {
|
||||
switch (type) {
|
||||
case 'incident':
|
||||
return <LocalFireDepartment />;
|
||||
case 'member':
|
||||
return <Person />;
|
||||
case 'vehicle':
|
||||
return <DirectionsCar />;
|
||||
case 'task':
|
||||
return <Assignment />;
|
||||
default:
|
||||
return <LocalFireDepartment />;
|
||||
}
|
||||
};
|
||||
|
||||
const getActivityColor = (type: Activity['type']) => {
|
||||
switch (type) {
|
||||
case 'incident':
|
||||
return 'error.main';
|
||||
case 'member':
|
||||
return 'success.main';
|
||||
case 'vehicle':
|
||||
return 'warning.main';
|
||||
case 'task':
|
||||
return 'info.main';
|
||||
default:
|
||||
return 'primary.main';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Letzte Aktivitäten
|
||||
</Typography>
|
||||
|
||||
<List sx={{ pt: 2 }}>
|
||||
{placeholderActivities.map((activity, index) => (
|
||||
<React.Fragment key={activity.id}>
|
||||
<ListItem
|
||||
alignItems="flex-start"
|
||||
sx={{
|
||||
px: 0,
|
||||
position: 'relative',
|
||||
'&::before':
|
||||
index < placeholderActivities.length - 1
|
||||
? {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
left: 19,
|
||||
top: 56,
|
||||
bottom: -8,
|
||||
width: 2,
|
||||
bgcolor: 'divider',
|
||||
}
|
||||
: {},
|
||||
}}
|
||||
>
|
||||
<ListItemAvatar>
|
||||
<Avatar
|
||||
sx={{
|
||||
bgcolor: getActivityColor(activity.type),
|
||||
width: 40,
|
||||
height: 40,
|
||||
}}
|
||||
>
|
||||
{getActivityIcon(activity.type)}
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Typography variant="subtitle2" component="span">
|
||||
{activity.title}
|
||||
</Typography>
|
||||
}
|
||||
secondary={
|
||||
<Box>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
component="span"
|
||||
sx={{ display: 'block' }}
|
||||
>
|
||||
{activity.description}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
component="span"
|
||||
>
|
||||
{activity.timestamp}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</List>
|
||||
|
||||
{placeholderActivities.length === 0 && (
|
||||
<Box sx={{ textAlign: 'center', py: 4 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Keine Aktivitäten vorhanden
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActivityFeed;
|
||||
21
frontend/src/components/dashboard/BookstackCard.tsx
Normal file
21
frontend/src/components/dashboard/BookstackCard.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
import { MenuBook } from '@mui/icons-material';
|
||||
import ServiceCard from './ServiceCard';
|
||||
|
||||
interface BookstackCardProps {
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
const BookstackCard: React.FC<BookstackCardProps> = ({ onClick }) => {
|
||||
return (
|
||||
<ServiceCard
|
||||
title="Bookstack"
|
||||
description="Dokumentation und Wiki"
|
||||
icon={MenuBook}
|
||||
status="disconnected"
|
||||
onClick={onClick}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default BookstackCard;
|
||||
46
frontend/src/components/dashboard/DashboardLayout.tsx
Normal file
46
frontend/src/components/dashboard/DashboardLayout.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { useState, ReactNode } from 'react';
|
||||
import { Box, Toolbar } from '@mui/material';
|
||||
import Header from '../shared/Header';
|
||||
import Sidebar from '../shared/Sidebar';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import Loading from '../shared/Loading';
|
||||
|
||||
interface DashboardLayoutProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const { isLoading } = useAuth();
|
||||
|
||||
const handleDrawerToggle = () => {
|
||||
setMobileOpen(!mobileOpen);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <Loading message="Lade Dashboard..." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex' }}>
|
||||
<Header onMenuClick={handleDrawerToggle} />
|
||||
<Sidebar mobileOpen={mobileOpen} onMobileClose={() => setMobileOpen(false)} />
|
||||
|
||||
<Box
|
||||
component="main"
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
p: 3,
|
||||
width: { sm: `calc(100% - 240px)` },
|
||||
minHeight: '100vh',
|
||||
backgroundColor: 'background.default',
|
||||
}}
|
||||
>
|
||||
<Toolbar />
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardLayout;
|
||||
21
frontend/src/components/dashboard/NextcloudCard.tsx
Normal file
21
frontend/src/components/dashboard/NextcloudCard.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
import { Cloud } from '@mui/icons-material';
|
||||
import ServiceCard from './ServiceCard';
|
||||
|
||||
interface NextcloudCardProps {
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
const NextcloudCard: React.FC<NextcloudCardProps> = ({ onClick }) => {
|
||||
return (
|
||||
<ServiceCard
|
||||
title="Nextcloud"
|
||||
description="Dateien und Dokumente"
|
||||
icon={Cloud}
|
||||
status="disconnected"
|
||||
onClick={onClick}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default NextcloudCard;
|
||||
106
frontend/src/components/dashboard/ServiceCard.tsx
Normal file
106
frontend/src/components/dashboard/ServiceCard.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardActionArea,
|
||||
CardContent,
|
||||
Typography,
|
||||
Box,
|
||||
Chip,
|
||||
} from '@mui/material';
|
||||
import { SvgIconComponent } from '@mui/icons-material';
|
||||
|
||||
interface ServiceCardProps {
|
||||
title: string;
|
||||
description: string;
|
||||
icon: SvgIconComponent;
|
||||
status: 'connected' | 'disconnected';
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
const ServiceCard: React.FC<ServiceCardProps> = ({
|
||||
title,
|
||||
description,
|
||||
icon: Icon,
|
||||
status,
|
||||
onClick,
|
||||
}) => {
|
||||
const isConnected = status === 'connected';
|
||||
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
height: '100%',
|
||||
transition: 'all 0.3s ease',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-4px)',
|
||||
boxShadow: 4,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CardActionArea
|
||||
onClick={onClick}
|
||||
sx={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'stretch',
|
||||
justifyContent: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ flexGrow: 1, width: '100%' }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
bgcolor: isConnected ? 'success.light' : 'grey.300',
|
||||
borderRadius: '50%',
|
||||
p: 1.5,
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
sx={{
|
||||
fontSize: 32,
|
||||
color: isConnected ? 'success.dark' : 'grey.600',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: '50%',
|
||||
bgcolor: isConnected ? 'success.main' : 'grey.400',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Typography variant="h6" component="div" gutterBottom>
|
||||
{title}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
{description}
|
||||
</Typography>
|
||||
|
||||
<Chip
|
||||
label={isConnected ? 'Verbunden' : 'Noch nicht konfiguriert'}
|
||||
size="small"
|
||||
color={isConnected ? 'success' : 'default'}
|
||||
variant={isConnected ? 'filled' : 'outlined'}
|
||||
/>
|
||||
</CardContent>
|
||||
</CardActionArea>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServiceCard;
|
||||
74
frontend/src/components/dashboard/StatsCard.tsx
Normal file
74
frontend/src/components/dashboard/StatsCard.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import React from 'react';
|
||||
import { Card, CardContent, Typography, Box } from '@mui/material';
|
||||
import { SvgIconComponent } from '@mui/icons-material';
|
||||
|
||||
interface StatsCardProps {
|
||||
title: string;
|
||||
value: string | number;
|
||||
icon: SvgIconComponent;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
const StatsCard: React.FC<StatsCardProps> = ({
|
||||
title,
|
||||
value,
|
||||
icon: Icon,
|
||||
color = 'primary.main',
|
||||
}) => {
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
height: '100%',
|
||||
transition: 'all 0.3s ease',
|
||||
'&:hover': {
|
||||
boxShadow: 3,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
gutterBottom
|
||||
sx={{ textTransform: 'uppercase', fontSize: '0.75rem' }}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
<Typography variant="h4" component="div" sx={{ fontWeight: 'bold' }}>
|
||||
{value}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bgcolor: `${color}15`,
|
||||
borderRadius: '50%',
|
||||
width: 56,
|
||||
height: 56,
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
sx={{
|
||||
fontSize: 32,
|
||||
color: color,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatsCard;
|
||||
137
frontend/src/components/dashboard/UserProfile.tsx
Normal file
137
frontend/src/components/dashboard/UserProfile.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
Avatar,
|
||||
Typography,
|
||||
Box,
|
||||
Chip,
|
||||
} from '@mui/material';
|
||||
import { User } from '../../types/auth.types';
|
||||
|
||||
interface UserProfileProps {
|
||||
user: User;
|
||||
}
|
||||
|
||||
const UserProfile: React.FC<UserProfileProps> = ({ user }) => {
|
||||
// Get first letter of name for avatar
|
||||
const getInitials = (name: string): string => {
|
||||
return name.charAt(0).toUpperCase();
|
||||
};
|
||||
|
||||
// Format date (placeholder until we have actual dates)
|
||||
const formatDate = (date?: string): string => {
|
||||
if (!date) return 'Nicht verfügbar';
|
||||
return new Date(date).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flexDirection: { xs: 'column', sm: 'row' },
|
||||
gap: 3,
|
||||
}}
|
||||
>
|
||||
{/* Avatar */}
|
||||
<Avatar
|
||||
sx={{
|
||||
width: 80,
|
||||
height: 80,
|
||||
bgcolor: 'rgba(255, 255, 255, 0.2)',
|
||||
fontSize: '2rem',
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
>
|
||||
{getInitials(user.name)}
|
||||
</Avatar>
|
||||
|
||||
{/* User Info */}
|
||||
<Box sx={{ flex: 1, textAlign: { xs: 'center', sm: 'left' } }}>
|
||||
<Typography variant="h5" component="div" gutterBottom>
|
||||
{user.name}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ opacity: 0.9 }}>
|
||||
{user.email}
|
||||
</Typography>
|
||||
{user.preferred_username && (
|
||||
<Typography variant="body2" sx={{ opacity: 0.9 }}>
|
||||
@{user.preferred_username}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
gap: 1,
|
||||
mt: 2,
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: { xs: 'center', sm: 'flex-start' },
|
||||
}}
|
||||
>
|
||||
<Chip
|
||||
label="Aktiv"
|
||||
size="small"
|
||||
sx={{
|
||||
bgcolor: 'rgba(255, 255, 255, 0.2)',
|
||||
color: 'white',
|
||||
}}
|
||||
/>
|
||||
{user.groups && user.groups.length > 0 && (
|
||||
<Chip
|
||||
label={`${user.groups.length} Gruppe${user.groups.length > 1 ? 'n' : ''}`}
|
||||
size="small"
|
||||
sx={{
|
||||
bgcolor: 'rgba(255, 255, 255, 0.2)',
|
||||
color: 'white',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Additional Info */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1,
|
||||
textAlign: { xs: 'center', sm: 'right' },
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ opacity: 0.8 }}>
|
||||
Letzter Login
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 'medium' }}>
|
||||
Heute
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ opacity: 0.8 }}>
|
||||
Mitglied seit
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 'medium' }}>
|
||||
{formatDate()}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserProfile;
|
||||
21
frontend/src/components/dashboard/VikunjaCard.tsx
Normal file
21
frontend/src/components/dashboard/VikunjaCard.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
import { Assignment } from '@mui/icons-material';
|
||||
import ServiceCard from './ServiceCard';
|
||||
|
||||
interface VikunjaCardProps {
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
const VikunjaCard: React.FC<VikunjaCardProps> = ({ onClick }) => {
|
||||
return (
|
||||
<ServiceCard
|
||||
title="Vikunja"
|
||||
description="Aufgaben und Projekte"
|
||||
icon={Assignment}
|
||||
status="disconnected"
|
||||
onClick={onClick}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default VikunjaCard;
|
||||
8
frontend/src/components/dashboard/index.ts
Normal file
8
frontend/src/components/dashboard/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export { default as UserProfile } from './UserProfile';
|
||||
export { default as ServiceCard } from './ServiceCard';
|
||||
export { default as NextcloudCard } from './NextcloudCard';
|
||||
export { default as VikunjaCard } from './VikunjaCard';
|
||||
export { default as BookstackCard } from './BookstackCard';
|
||||
export { default as StatsCard } from './StatsCard';
|
||||
export { default as ActivityFeed } from './ActivityFeed';
|
||||
export { default as DashboardLayout } from './DashboardLayout';
|
||||
55
frontend/src/components/shared/EmptyState.tsx
Normal file
55
frontend/src/components/shared/EmptyState.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Box, Typography, Button } from '@mui/material';
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
message: string;
|
||||
action?: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
const EmptyState: React.FC<EmptyStateProps> = ({ icon, title, message, action }) => {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
py: 8,
|
||||
px: 3,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
fontSize: 80,
|
||||
color: 'text.disabled',
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</Box>
|
||||
<Typography variant="h5" component="h2" gutterBottom color="text.primary">
|
||||
{title}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
color="text.secondary"
|
||||
sx={{ mb: action ? 3 : 0, maxWidth: 500 }}
|
||||
>
|
||||
{message}
|
||||
</Typography>
|
||||
{action && (
|
||||
<Button variant="contained" color="primary" onClick={action.onClick}>
|
||||
{action.label}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmptyState;
|
||||
137
frontend/src/components/shared/ErrorBoundary.tsx
Normal file
137
frontend/src/components/shared/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import React, { Component, ErrorInfo, ReactNode } from 'react';
|
||||
import { Box, Card, CardContent, Typography, Button } from '@mui/material';
|
||||
import { ErrorOutline, Refresh } from '@mui/icons-material';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
errorInfo: ErrorInfo | null;
|
||||
}
|
||||
|
||||
class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
hasError: false,
|
||||
error: null,
|
||||
errorInfo: null,
|
||||
};
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): Partial<State> {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
|
||||
console.error('ErrorBoundary caught an error:', error);
|
||||
console.error('Error Info:', errorInfo);
|
||||
this.setState({ error, errorInfo });
|
||||
}
|
||||
|
||||
handleReset = (): void => {
|
||||
this.setState({
|
||||
hasError: false,
|
||||
error: null,
|
||||
errorInfo: null,
|
||||
});
|
||||
};
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: '100vh',
|
||||
bgcolor: 'background.default',
|
||||
p: 3,
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
sx={{
|
||||
maxWidth: 600,
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<CardContent
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
p: 4,
|
||||
}}
|
||||
>
|
||||
<ErrorOutline
|
||||
sx={{
|
||||
fontSize: 80,
|
||||
color: 'error.main',
|
||||
mb: 2,
|
||||
}}
|
||||
/>
|
||||
<Typography variant="h5" component="h1" gutterBottom align="center">
|
||||
Etwas ist schiefgelaufen
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
color="text.secondary"
|
||||
align="center"
|
||||
sx={{ mb: 3 }}
|
||||
>
|
||||
Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es
|
||||
erneut.
|
||||
</Typography>
|
||||
|
||||
{this.state.error && (
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
bgcolor: 'grey.100',
|
||||
p: 2,
|
||||
borderRadius: 1,
|
||||
mb: 3,
|
||||
maxHeight: 200,
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="caption"
|
||||
component="pre"
|
||||
sx={{
|
||||
fontFamily: 'monospace',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
{this.state.error.toString()}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
size="large"
|
||||
startIcon={<Refresh />}
|
||||
onClick={this.handleReset}
|
||||
fullWidth
|
||||
>
|
||||
Erneut versuchen
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
161
frontend/src/components/shared/Header.tsx
Normal file
161
frontend/src/components/shared/Header.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
AppBar,
|
||||
Toolbar,
|
||||
Typography,
|
||||
IconButton,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Avatar,
|
||||
ListItemIcon,
|
||||
Divider,
|
||||
Box,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
LocalFireDepartment,
|
||||
Person,
|
||||
Settings,
|
||||
Logout,
|
||||
Menu as MenuIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
interface HeaderProps {
|
||||
onMenuClick: () => void;
|
||||
}
|
||||
|
||||
function Header({ onMenuClick }: HeaderProps) {
|
||||
const { user, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
|
||||
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
|
||||
const handleMenuClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const handleProfile = () => {
|
||||
handleMenuClose();
|
||||
navigate('/profile');
|
||||
};
|
||||
|
||||
const handleSettings = () => {
|
||||
handleMenuClose();
|
||||
navigate('/settings');
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
handleMenuClose();
|
||||
logout();
|
||||
};
|
||||
|
||||
// Get initials for avatar
|
||||
const getInitials = () => {
|
||||
if (!user) return '?';
|
||||
const initials = (user.given_name?.[0] || '') + (user.family_name?.[0] || '');
|
||||
return initials || user.name?.[0] || '?';
|
||||
};
|
||||
|
||||
return (
|
||||
<AppBar
|
||||
position="fixed"
|
||||
sx={{
|
||||
zIndex: (theme) => theme.zIndex.drawer + 1,
|
||||
}}
|
||||
>
|
||||
<Toolbar>
|
||||
<IconButton
|
||||
color="inherit"
|
||||
aria-label="Menü öffnen"
|
||||
edge="start"
|
||||
onClick={onMenuClick}
|
||||
sx={{ mr: 2, display: { sm: 'none' } }}
|
||||
>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
|
||||
<LocalFireDepartment sx={{ mr: 2 }} />
|
||||
<Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
|
||||
Feuerwehr Dashboard
|
||||
</Typography>
|
||||
|
||||
{user && (
|
||||
<>
|
||||
<IconButton
|
||||
onClick={handleMenuOpen}
|
||||
size="small"
|
||||
aria-label="Benutzerkonto"
|
||||
aria-controls="user-menu"
|
||||
aria-haspopup="true"
|
||||
>
|
||||
<Avatar
|
||||
sx={{
|
||||
bgcolor: 'secondary.main',
|
||||
width: 32,
|
||||
height: 32,
|
||||
fontSize: '0.875rem',
|
||||
}}
|
||||
>
|
||||
{getInitials()}
|
||||
</Avatar>
|
||||
</IconButton>
|
||||
|
||||
<Menu
|
||||
id="user-menu"
|
||||
anchorEl={anchorEl}
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={handleMenuClose}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
PaperProps={{
|
||||
elevation: 3,
|
||||
sx: { minWidth: 250, mt: 1 },
|
||||
}}
|
||||
>
|
||||
<Box sx={{ px: 2, py: 1.5 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||||
{user.name}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{user.email}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Divider />
|
||||
<MenuItem onClick={handleProfile}>
|
||||
<ListItemIcon>
|
||||
<Person fontSize="small" />
|
||||
</ListItemIcon>
|
||||
Profil
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleSettings}>
|
||||
<ListItemIcon>
|
||||
<Settings fontSize="small" />
|
||||
</ListItemIcon>
|
||||
Einstellungen
|
||||
</MenuItem>
|
||||
<Divider />
|
||||
<MenuItem onClick={handleLogout}>
|
||||
<ListItemIcon>
|
||||
<Logout fontSize="small" />
|
||||
</ListItemIcon>
|
||||
Abmelden
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</>
|
||||
)}
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
);
|
||||
}
|
||||
|
||||
export default Header;
|
||||
29
frontend/src/components/shared/Loading.tsx
Normal file
29
frontend/src/components/shared/Loading.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Box, CircularProgress, Typography } from '@mui/material';
|
||||
|
||||
interface LoadingProps {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
function Loading({ message }: LoadingProps) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: '100vh',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<CircularProgress />
|
||||
{message && (
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
{message}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default Loading;
|
||||
156
frontend/src/components/shared/Sidebar.tsx
Normal file
156
frontend/src/components/shared/Sidebar.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import {
|
||||
Drawer,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemButton,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Toolbar,
|
||||
Tooltip,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Dashboard as DashboardIcon,
|
||||
LocalFireDepartment,
|
||||
DirectionsCar,
|
||||
Build,
|
||||
People,
|
||||
} from '@mui/icons-material';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
|
||||
const DRAWER_WIDTH = 240;
|
||||
|
||||
interface NavigationItem {
|
||||
text: string;
|
||||
icon: JSX.Element;
|
||||
path: string;
|
||||
}
|
||||
|
||||
const navigationItems: NavigationItem[] = [
|
||||
{
|
||||
text: 'Dashboard',
|
||||
icon: <DashboardIcon />,
|
||||
path: '/dashboard',
|
||||
},
|
||||
{
|
||||
text: 'Einsätze',
|
||||
icon: <LocalFireDepartment />,
|
||||
path: '/einsaetze',
|
||||
},
|
||||
{
|
||||
text: 'Fahrzeuge',
|
||||
icon: <DirectionsCar />,
|
||||
path: '/fahrzeuge',
|
||||
},
|
||||
{
|
||||
text: 'Ausrüstung',
|
||||
icon: <Build />,
|
||||
path: '/ausruestung',
|
||||
},
|
||||
{
|
||||
text: 'Mitglieder',
|
||||
icon: <People />,
|
||||
path: '/mitglieder',
|
||||
},
|
||||
];
|
||||
|
||||
interface SidebarProps {
|
||||
mobileOpen: boolean;
|
||||
onMobileClose: () => void;
|
||||
}
|
||||
|
||||
function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const handleNavigation = (path: string) => {
|
||||
navigate(path);
|
||||
onMobileClose();
|
||||
};
|
||||
|
||||
const drawerContent = (
|
||||
<>
|
||||
<Toolbar />
|
||||
<List>
|
||||
{navigationItems.map((item) => {
|
||||
const isActive = location.pathname === item.path;
|
||||
return (
|
||||
<ListItem key={item.text} disablePadding>
|
||||
<Tooltip title={item.text} placement="right" arrow>
|
||||
<ListItemButton
|
||||
selected={isActive}
|
||||
onClick={() => handleNavigation(item.path)}
|
||||
aria-label={`Zu ${item.text} navigieren`}
|
||||
sx={{
|
||||
'&.Mui-selected': {
|
||||
backgroundColor: 'primary.light',
|
||||
color: 'primary.contrastText',
|
||||
'&:hover': {
|
||||
backgroundColor: 'primary.main',
|
||||
},
|
||||
'& .MuiListItemIcon-root': {
|
||||
color: 'primary.contrastText',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ListItemIcon
|
||||
sx={{
|
||||
color: isActive ? 'inherit' : 'text.secondary',
|
||||
}}
|
||||
>
|
||||
{item.icon}
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={item.text} />
|
||||
</ListItemButton>
|
||||
</Tooltip>
|
||||
</ListItem>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile drawer */}
|
||||
<Drawer
|
||||
variant="temporary"
|
||||
open={mobileOpen}
|
||||
onClose={onMobileClose}
|
||||
ModalProps={{
|
||||
keepMounted: true, // Better mobile performance
|
||||
}}
|
||||
sx={{
|
||||
display: { xs: 'block', sm: 'none' },
|
||||
'& .MuiDrawer-paper': {
|
||||
boxSizing: 'border-box',
|
||||
width: DRAWER_WIDTH,
|
||||
},
|
||||
}}
|
||||
aria-label="Mobile Navigation"
|
||||
>
|
||||
{drawerContent}
|
||||
</Drawer>
|
||||
|
||||
{/* Desktop drawer */}
|
||||
<Drawer
|
||||
variant="permanent"
|
||||
sx={{
|
||||
display: { xs: 'none', sm: 'block' },
|
||||
width: DRAWER_WIDTH,
|
||||
flexShrink: 0,
|
||||
'& .MuiDrawer-paper': {
|
||||
width: DRAWER_WIDTH,
|
||||
boxSizing: 'border-box',
|
||||
},
|
||||
}}
|
||||
open
|
||||
aria-label="Desktop Navigation"
|
||||
>
|
||||
{drawerContent}
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Sidebar;
|
||||
44
frontend/src/components/shared/SkeletonCard.tsx
Normal file
44
frontend/src/components/shared/SkeletonCard.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
import { Card, CardContent, Skeleton, Box } from '@mui/material';
|
||||
|
||||
interface SkeletonCardProps {
|
||||
variant?: 'basic' | 'withAvatar' | 'detailed';
|
||||
}
|
||||
|
||||
const SkeletonCard: React.FC<SkeletonCardProps> = ({ variant = 'basic' }) => {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
{variant === 'withAvatar' && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
<Skeleton variant="circular" width={40} height={40} sx={{ mr: 2 }} />
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Skeleton variant="text" width="60%" height={24} />
|
||||
<Skeleton variant="text" width="40%" height={20} />
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{variant === 'detailed' && (
|
||||
<>
|
||||
<Skeleton variant="text" width="80%" height={32} sx={{ mb: 1 }} />
|
||||
<Skeleton variant="text" width="100%" height={20} sx={{ mb: 1 }} />
|
||||
<Skeleton variant="text" width="100%" height={20} sx={{ mb: 1 }} />
|
||||
<Skeleton variant="text" width="60%" height={20} sx={{ mb: 2 }} />
|
||||
<Skeleton variant="rectangular" width="100%" height={140} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{variant === 'basic' && (
|
||||
<>
|
||||
<Skeleton variant="text" width="70%" height={28} sx={{ mb: 1 }} />
|
||||
<Skeleton variant="text" width="100%" height={20} sx={{ mb: 1 }} />
|
||||
<Skeleton variant="text" width="90%" height={20} />
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default SkeletonCard;
|
||||
7
frontend/src/components/shared/index.ts
Normal file
7
frontend/src/components/shared/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
// Shared components barrel export
|
||||
export { default as ErrorBoundary } from './ErrorBoundary';
|
||||
export { default as EmptyState } from './EmptyState';
|
||||
export { default as SkeletonCard } from './SkeletonCard';
|
||||
export { default as Header } from './Header';
|
||||
export { default as Sidebar } from './Sidebar';
|
||||
export { default as Loading } from './Loading';
|
||||
151
frontend/src/contexts/AuthContext.tsx
Normal file
151
frontend/src/contexts/AuthContext.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
import { AuthContextType, AuthState, User } from '../types/auth.types';
|
||||
import { authService } from '../services/auth';
|
||||
import { getToken, setToken, removeToken, getUser, setUser, removeUser } from '../utils/storage';
|
||||
import { useNotification } from './NotificationContext';
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
interface AuthProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
||||
const notification = useNotification();
|
||||
const [state, setState] = useState<AuthState>({
|
||||
user: null,
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: true,
|
||||
});
|
||||
|
||||
// Check for existing token on mount
|
||||
useEffect(() => {
|
||||
const initializeAuth = async () => {
|
||||
const token = getToken();
|
||||
const user = getUser();
|
||||
|
||||
if (token && user) {
|
||||
setState({
|
||||
user,
|
||||
token,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
// Optionally verify token is still valid
|
||||
try {
|
||||
await authService.getCurrentUser();
|
||||
} catch (error) {
|
||||
console.error('Token validation failed:', error);
|
||||
// Token is invalid, clear it
|
||||
removeToken();
|
||||
removeUser();
|
||||
setState({
|
||||
user: null,
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setState({
|
||||
user: null,
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
initializeAuth();
|
||||
}, []);
|
||||
|
||||
const login = async (code: string): Promise<void> => {
|
||||
try {
|
||||
setState((prev) => ({ ...prev, isLoading: true }));
|
||||
|
||||
const { token, user } = await authService.handleCallback(code);
|
||||
|
||||
// Save to localStorage
|
||||
setToken(token);
|
||||
setUser(user);
|
||||
|
||||
// Update state
|
||||
setState({
|
||||
user,
|
||||
token,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
// Show success notification
|
||||
notification.showSuccess('Anmeldung erfolgreich');
|
||||
} catch (error) {
|
||||
console.error('Login failed:', error);
|
||||
setState({
|
||||
user: null,
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
// Show error notification
|
||||
notification.showError('Anmeldung fehlgeschlagen. Bitte versuchen Sie es erneut.');
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const logout = (): void => {
|
||||
// Call backend logout (fire and forget)
|
||||
authService.logout().catch((error) => {
|
||||
console.error('Backend logout failed:', error);
|
||||
});
|
||||
|
||||
// Clear local state
|
||||
removeToken();
|
||||
removeUser();
|
||||
setState({
|
||||
user: null,
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
// Show logout notification
|
||||
notification.showSuccess('Abmeldung erfolgreich');
|
||||
|
||||
// Redirect to login after a short delay to show notification
|
||||
setTimeout(() => {
|
||||
window.location.href = '/login';
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const refreshAuth = async (): Promise<void> => {
|
||||
try {
|
||||
const user = await authService.getCurrentUser();
|
||||
setUser(user);
|
||||
setState((prev) => ({ ...prev, user }));
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh user data:', error);
|
||||
logout();
|
||||
}
|
||||
};
|
||||
|
||||
const value: AuthContextType = {
|
||||
...state,
|
||||
login,
|
||||
logout,
|
||||
refreshAuth,
|
||||
};
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
};
|
||||
|
||||
export const useAuth = (): AuthContextType => {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
109
frontend/src/contexts/NotificationContext.tsx
Normal file
109
frontend/src/contexts/NotificationContext.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import React, { createContext, useContext, useState, ReactNode, useCallback } from 'react';
|
||||
import { Snackbar, Alert, AlertColor } from '@mui/material';
|
||||
|
||||
interface Notification {
|
||||
id: number;
|
||||
message: string;
|
||||
severity: AlertColor;
|
||||
}
|
||||
|
||||
interface NotificationContextType {
|
||||
showSuccess: (message: string) => void;
|
||||
showError: (message: string) => void;
|
||||
showWarning: (message: string) => void;
|
||||
showInfo: (message: string) => void;
|
||||
}
|
||||
|
||||
const NotificationContext = createContext<NotificationContextType | undefined>(undefined);
|
||||
|
||||
interface NotificationProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const NotificationProvider: React.FC<NotificationProviderProps> = ({ children }) => {
|
||||
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||
const [currentNotification, setCurrentNotification] = useState<Notification | null>(null);
|
||||
|
||||
const addNotification = useCallback((message: string, severity: AlertColor) => {
|
||||
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]);
|
||||
|
||||
const showSuccess = useCallback((message: string) => {
|
||||
addNotification(message, 'success');
|
||||
}, [addNotification]);
|
||||
|
||||
const showError = useCallback((message: string) => {
|
||||
addNotification(message, 'error');
|
||||
}, [addNotification]);
|
||||
|
||||
const showWarning = useCallback((message: string) => {
|
||||
addNotification(message, 'warning');
|
||||
}, [addNotification]);
|
||||
|
||||
const showInfo = useCallback((message: string) => {
|
||||
addNotification(message, 'info');
|
||||
}, [addNotification]);
|
||||
|
||||
const handleClose = (_event?: React.SyntheticEvent | Event, reason?: string) => {
|
||||
if (reason === 'clickaway') {
|
||||
return;
|
||||
}
|
||||
|
||||
setCurrentNotification(null);
|
||||
|
||||
// Show next 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;
|
||||
});
|
||||
}, 200);
|
||||
};
|
||||
|
||||
const value: NotificationContextType = {
|
||||
showSuccess,
|
||||
showError,
|
||||
showWarning,
|
||||
showInfo,
|
||||
};
|
||||
|
||||
return (
|
||||
<NotificationContext.Provider value={value}>
|
||||
{children}
|
||||
<Snackbar
|
||||
open={currentNotification !== null}
|
||||
autoHideDuration={6000}
|
||||
onClose={handleClose}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
>
|
||||
<Alert
|
||||
onClose={handleClose}
|
||||
severity={currentNotification?.severity || 'info'}
|
||||
variant="filled"
|
||||
sx={{ width: '100%' }}
|
||||
>
|
||||
{currentNotification?.message}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
</NotificationContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useNotification = (): NotificationContextType => {
|
||||
const context = useContext(NotificationContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useNotification must be used within a NotificationProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
17
frontend/src/main.tsx
Normal file
17
frontend/src/main.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { CssBaseline, ThemeProvider } from '@mui/material';
|
||||
import { lightTheme } from './theme/theme';
|
||||
import App from './App';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<CssBaseline />
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
69
frontend/src/pages/Ausruestung.tsx
Normal file
69
frontend/src/pages/Ausruestung.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import {
|
||||
Container,
|
||||
Typography,
|
||||
Card,
|
||||
CardContent,
|
||||
Box,
|
||||
} from '@mui/material';
|
||||
import { Build } from '@mui/icons-material';
|
||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
|
||||
function Ausruestung() {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<Container maxWidth="lg">
|
||||
<Typography variant="h4" gutterBottom sx={{ mb: 4 }}>
|
||||
Ausrüstungsverwaltung
|
||||
</Typography>
|
||||
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
<Build color="primary" sx={{ fontSize: 48, mr: 2 }} />
|
||||
<Box>
|
||||
<Typography variant="h6">Ausrüstung</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Diese Funktion wird in Kürze verfügbar sein
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<Typography variant="body1" color="text.secondary" paragraph>
|
||||
Geplante Features:
|
||||
</Typography>
|
||||
<ul>
|
||||
<li>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Inventarverwaltung
|
||||
</Typography>
|
||||
</li>
|
||||
<li>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Wartungsprüfungen und -protokolle
|
||||
</Typography>
|
||||
</li>
|
||||
<li>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Prüffristen und Erinnerungen
|
||||
</Typography>
|
||||
</li>
|
||||
<li>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Schutzausrüstung (PSA)
|
||||
</Typography>
|
||||
</li>
|
||||
<li>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Atemschutzgeräte und -wartung
|
||||
</Typography>
|
||||
</li>
|
||||
</ul>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Container>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default Ausruestung;
|
||||
203
frontend/src/pages/Dashboard.tsx
Normal file
203
frontend/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Container,
|
||||
Box,
|
||||
Typography,
|
||||
Grid,
|
||||
Fade,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
People,
|
||||
Warning,
|
||||
EventNote,
|
||||
LocalFireDepartment,
|
||||
} from '@mui/icons-material';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
import SkeletonCard from '../components/shared/SkeletonCard';
|
||||
import UserProfile from '../components/dashboard/UserProfile';
|
||||
import NextcloudCard from '../components/dashboard/NextcloudCard';
|
||||
import VikunjaCard from '../components/dashboard/VikunjaCard';
|
||||
import BookstackCard from '../components/dashboard/BookstackCard';
|
||||
import StatsCard from '../components/dashboard/StatsCard';
|
||||
import ActivityFeed from '../components/dashboard/ActivityFeed';
|
||||
|
||||
function Dashboard() {
|
||||
const { user } = useAuth();
|
||||
const [dataLoading, setDataLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Simulate loading data
|
||||
const timer = setTimeout(() => {
|
||||
setDataLoading(false);
|
||||
}, 800);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<Container maxWidth="lg">
|
||||
<Grid container spacing={3}>
|
||||
{/* Welcome Message */}
|
||||
<Grid item xs={12}>
|
||||
{dataLoading ? (
|
||||
<SkeletonCard variant="basic" />
|
||||
) : (
|
||||
<Fade in={true} timeout={600}>
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
Willkommen zurück, {user?.given_name || user?.name.split(' ')[0]}!
|
||||
</Typography>
|
||||
</Box>
|
||||
</Fade>
|
||||
)}
|
||||
</Grid>
|
||||
|
||||
{/* User Profile Card */}
|
||||
{user && (
|
||||
<Grid item xs={12}>
|
||||
{dataLoading ? (
|
||||
<SkeletonCard variant="detailed" />
|
||||
) : (
|
||||
<Fade in={true} timeout={600} style={{ transitionDelay: '100ms' }}>
|
||||
<Box>
|
||||
<UserProfile user={user} />
|
||||
</Box>
|
||||
</Fade>
|
||||
)}
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{/* Stats Cards Row */}
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
{dataLoading ? (
|
||||
<SkeletonCard variant="basic" />
|
||||
) : (
|
||||
<Fade in={true} timeout={600} style={{ transitionDelay: '200ms' }}>
|
||||
<Box>
|
||||
<StatsCard
|
||||
title="Aktive Mitglieder"
|
||||
value="24"
|
||||
icon={People}
|
||||
color="primary.main"
|
||||
/>
|
||||
</Box>
|
||||
</Fade>
|
||||
)}
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
{dataLoading ? (
|
||||
<SkeletonCard variant="basic" />
|
||||
) : (
|
||||
<Fade in={true} timeout={600} style={{ transitionDelay: '250ms' }}>
|
||||
<Box>
|
||||
<StatsCard
|
||||
title="Einsätze (Jahr)"
|
||||
value="18"
|
||||
icon={Warning}
|
||||
color="error.main"
|
||||
/>
|
||||
</Box>
|
||||
</Fade>
|
||||
)}
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
{dataLoading ? (
|
||||
<SkeletonCard variant="basic" />
|
||||
) : (
|
||||
<Fade in={true} timeout={600} style={{ transitionDelay: '300ms' }}>
|
||||
<Box>
|
||||
<StatsCard
|
||||
title="Offene Aufgaben"
|
||||
value="7"
|
||||
icon={EventNote}
|
||||
color="warning.main"
|
||||
/>
|
||||
</Box>
|
||||
</Fade>
|
||||
)}
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
{dataLoading ? (
|
||||
<SkeletonCard variant="basic" />
|
||||
) : (
|
||||
<Fade in={true} timeout={600} style={{ transitionDelay: '350ms' }}>
|
||||
<Box>
|
||||
<StatsCard
|
||||
title="Fahrzeuge"
|
||||
value="5"
|
||||
icon={LocalFireDepartment}
|
||||
color="success.main"
|
||||
/>
|
||||
</Box>
|
||||
</Fade>
|
||||
)}
|
||||
</Grid>
|
||||
|
||||
{/* Service Integration Cards */}
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="h6" gutterBottom sx={{ mt: 2 }}>
|
||||
Dienste und Integrationen
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={4}>
|
||||
{dataLoading ? (
|
||||
<SkeletonCard variant="basic" />
|
||||
) : (
|
||||
<Fade in={true} timeout={600} style={{ transitionDelay: '400ms' }}>
|
||||
<Box>
|
||||
<NextcloudCard
|
||||
onClick={() => console.log('Nextcloud clicked')}
|
||||
/>
|
||||
</Box>
|
||||
</Fade>
|
||||
)}
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
{dataLoading ? (
|
||||
<SkeletonCard variant="basic" />
|
||||
) : (
|
||||
<Fade in={true} timeout={600} style={{ transitionDelay: '450ms' }}>
|
||||
<Box>
|
||||
<VikunjaCard
|
||||
onClick={() => console.log('Vikunja clicked')}
|
||||
/>
|
||||
</Box>
|
||||
</Fade>
|
||||
)}
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
{dataLoading ? (
|
||||
<SkeletonCard variant="basic" />
|
||||
) : (
|
||||
<Fade in={true} timeout={600} style={{ transitionDelay: '500ms' }}>
|
||||
<Box>
|
||||
<BookstackCard
|
||||
onClick={() => console.log('Bookstack clicked')}
|
||||
/>
|
||||
</Box>
|
||||
</Fade>
|
||||
)}
|
||||
</Grid>
|
||||
|
||||
{/* Activity Feed */}
|
||||
<Grid item xs={12}>
|
||||
{dataLoading ? (
|
||||
<SkeletonCard variant="detailed" />
|
||||
) : (
|
||||
<Fade in={true} timeout={600} style={{ transitionDelay: '550ms' }}>
|
||||
<Box>
|
||||
<ActivityFeed />
|
||||
</Box>
|
||||
</Fade>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Container>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default Dashboard;
|
||||
69
frontend/src/pages/Einsaetze.tsx
Normal file
69
frontend/src/pages/Einsaetze.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import {
|
||||
Container,
|
||||
Typography,
|
||||
Card,
|
||||
CardContent,
|
||||
Box,
|
||||
} from '@mui/material';
|
||||
import { LocalFireDepartment } from '@mui/icons-material';
|
||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
|
||||
function Einsaetze() {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<Container maxWidth="lg">
|
||||
<Typography variant="h4" gutterBottom sx={{ mb: 4 }}>
|
||||
Einsatzübersicht
|
||||
</Typography>
|
||||
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
<LocalFireDepartment color="primary" sx={{ fontSize: 48, mr: 2 }} />
|
||||
<Box>
|
||||
<Typography variant="h6">Einsatzverwaltung</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Diese Funktion wird in Kürze verfügbar sein
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<Typography variant="body1" color="text.secondary" paragraph>
|
||||
Geplante Features:
|
||||
</Typography>
|
||||
<ul>
|
||||
<li>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Einsatzliste mit Filteroptionen
|
||||
</Typography>
|
||||
</li>
|
||||
<li>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Einsatzberichte erstellen und verwalten
|
||||
</Typography>
|
||||
</li>
|
||||
<li>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Statistiken und Auswertungen
|
||||
</Typography>
|
||||
</li>
|
||||
<li>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Einsatzdokumentation
|
||||
</Typography>
|
||||
</li>
|
||||
<li>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Alarmstufen und Kategorien
|
||||
</Typography>
|
||||
</li>
|
||||
</ul>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Container>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default Einsaetze;
|
||||
69
frontend/src/pages/Fahrzeuge.tsx
Normal file
69
frontend/src/pages/Fahrzeuge.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import {
|
||||
Container,
|
||||
Typography,
|
||||
Card,
|
||||
CardContent,
|
||||
Box,
|
||||
} from '@mui/material';
|
||||
import { DirectionsCar } from '@mui/icons-material';
|
||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
|
||||
function Fahrzeuge() {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<Container maxWidth="lg">
|
||||
<Typography variant="h4" gutterBottom sx={{ mb: 4 }}>
|
||||
Fahrzeugverwaltung
|
||||
</Typography>
|
||||
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
<DirectionsCar color="primary" sx={{ fontSize: 48, mr: 2 }} />
|
||||
<Box>
|
||||
<Typography variant="h6">Fahrzeuge</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Diese Funktion wird in Kürze verfügbar sein
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<Typography variant="body1" color="text.secondary" paragraph>
|
||||
Geplante Features:
|
||||
</Typography>
|
||||
<ul>
|
||||
<li>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Fahrzeugliste mit Details
|
||||
</Typography>
|
||||
</li>
|
||||
<li>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Wartungspläne und -historie
|
||||
</Typography>
|
||||
</li>
|
||||
<li>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Tankbuch und Kilometerstände
|
||||
</Typography>
|
||||
</li>
|
||||
<li>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
TÜV/HU Erinnerungen
|
||||
</Typography>
|
||||
</li>
|
||||
<li>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Fahrzeugdokumentation
|
||||
</Typography>
|
||||
</li>
|
||||
</ul>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Container>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default Fahrzeuge;
|
||||
122
frontend/src/pages/Login.tsx
Normal file
122
frontend/src/pages/Login.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Container,
|
||||
Box,
|
||||
Paper,
|
||||
Button,
|
||||
Typography,
|
||||
CircularProgress,
|
||||
Fade,
|
||||
} from '@mui/material';
|
||||
import { LocalFireDepartment, Login as LoginIcon } from '@mui/icons-material';
|
||||
import { authService } from '../services/auth';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
function Login() {
|
||||
const navigate = useNavigate();
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
const [isRedirecting, setIsRedirecting] = useState(false);
|
||||
|
||||
// Redirect to dashboard if already authenticated
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
setIsRedirecting(true);
|
||||
navigate('/dashboard', { replace: true });
|
||||
}
|
||||
}, [isAuthenticated, navigate]);
|
||||
|
||||
const handleLogin = () => {
|
||||
try {
|
||||
const authUrl = authService.getAuthUrl();
|
||||
window.location.href = authUrl;
|
||||
} catch (error) {
|
||||
console.error('Failed to initiate login:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading || isRedirecting) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: '100vh',
|
||||
bgcolor: 'background.default',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<CircularProgress size={60} />
|
||||
<Typography variant="body1" sx={{ mt: 2 }} color="text.secondary">
|
||||
{isRedirecting ? 'Weiterleitung...' : 'Lade...'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container component="main" maxWidth="xs">
|
||||
<Box
|
||||
sx={{
|
||||
marginTop: 8,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
minHeight: '100vh',
|
||||
}}
|
||||
>
|
||||
<Fade in={true} timeout={800}>
|
||||
<Paper
|
||||
elevation={3}
|
||||
sx={{
|
||||
padding: 4,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<LocalFireDepartment sx={{ fontSize: 60, color: 'primary.main', mb: 2 }} />
|
||||
<Typography component="h1" variant="h5" gutterBottom>
|
||||
Feuerwehr Dashboard
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3, textAlign: 'center' }}>
|
||||
Bitte melden Sie sich mit Ihrem Authentik-Konto an
|
||||
</Typography>
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
size="large"
|
||||
onClick={handleLogin}
|
||||
startIcon={<LoginIcon />}
|
||||
sx={{ mt: 2 }}
|
||||
aria-label="Mit Authentik anmelden"
|
||||
>
|
||||
Mit Authentik anmelden
|
||||
</Button>
|
||||
</Paper>
|
||||
</Fade>
|
||||
|
||||
<Box
|
||||
component="footer"
|
||||
sx={{
|
||||
mt: 'auto',
|
||||
py: 3,
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" color="text.secondary" align="center" display="block">
|
||||
Feuerwehr Dashboard v0.0.1
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" align="center" display="block">
|
||||
{new Date().getFullYear()}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default Login;
|
||||
69
frontend/src/pages/Mitglieder.tsx
Normal file
69
frontend/src/pages/Mitglieder.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import {
|
||||
Container,
|
||||
Typography,
|
||||
Card,
|
||||
CardContent,
|
||||
Box,
|
||||
} from '@mui/material';
|
||||
import { People } from '@mui/icons-material';
|
||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
|
||||
function Mitglieder() {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<Container maxWidth="lg">
|
||||
<Typography variant="h4" gutterBottom sx={{ mb: 4 }}>
|
||||
Mitgliederverwaltung
|
||||
</Typography>
|
||||
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
<People color="primary" sx={{ fontSize: 48, mr: 2 }} />
|
||||
<Box>
|
||||
<Typography variant="h6">Mitglieder</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Diese Funktion wird in Kürze verfügbar sein
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<Typography variant="body1" color="text.secondary" paragraph>
|
||||
Geplante Features:
|
||||
</Typography>
|
||||
<ul>
|
||||
<li>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Mitgliederliste mit Kontaktdaten
|
||||
</Typography>
|
||||
</li>
|
||||
<li>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Qualifikationen und Lehrgänge
|
||||
</Typography>
|
||||
</li>
|
||||
<li>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Anwesenheitsverwaltung
|
||||
</Typography>
|
||||
</li>
|
||||
<li>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Dienstpläne und -einteilungen
|
||||
</Typography>
|
||||
</li>
|
||||
<li>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Atemschutz-G26 Untersuchungen
|
||||
</Typography>
|
||||
</li>
|
||||
</ul>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Container>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default Mitglieder;
|
||||
50
frontend/src/pages/NotFound.tsx
Normal file
50
frontend/src/pages/NotFound.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Container, Box, Typography, Button, Paper } from '@mui/material';
|
||||
import { Home } from '@mui/icons-material';
|
||||
|
||||
function NotFound() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<Container component="main" maxWidth="sm">
|
||||
<Box
|
||||
sx={{
|
||||
marginTop: 8,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Paper
|
||||
elevation={3}
|
||||
sx={{
|
||||
padding: 4,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<Typography variant="h1" component="h1" color="error" gutterBottom>
|
||||
404
|
||||
</Typography>
|
||||
<Typography variant="h5" component="h2" gutterBottom>
|
||||
Seite nicht gefunden
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
|
||||
Die angeforderte Seite existiert nicht.
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<Home />}
|
||||
onClick={() => navigate('/dashboard')}
|
||||
>
|
||||
Zurück zum Dashboard
|
||||
</Button>
|
||||
</Paper>
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default NotFound;
|
||||
262
frontend/src/pages/Profile.tsx
Normal file
262
frontend/src/pages/Profile.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
import {
|
||||
Container,
|
||||
Paper,
|
||||
Box,
|
||||
Typography,
|
||||
Avatar,
|
||||
Grid,
|
||||
TextField,
|
||||
Card,
|
||||
CardContent,
|
||||
Divider,
|
||||
Chip,
|
||||
} from '@mui/material';
|
||||
import { Person, Email, Badge, Group, AccessTime } from '@mui/icons-material';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
|
||||
function Profile() {
|
||||
const { user } = useAuth();
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get initials for large avatar
|
||||
const getInitials = () => {
|
||||
const initials = (user.given_name?.[0] || '') + (user.family_name?.[0] || '');
|
||||
return initials || user.name?.[0] || '?';
|
||||
};
|
||||
|
||||
// Format date (if we had lastLogin)
|
||||
const formatDate = (date?: Date | string) => {
|
||||
if (!date) return 'Nicht verfügbar';
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
return d.toLocaleDateString('de-DE', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<Container maxWidth="lg">
|
||||
<Typography variant="h4" gutterBottom sx={{ mb: 4 }}>
|
||||
Mein Profil
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
{/* User Info Card */}
|
||||
<Grid item xs={12} md={4}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
py: 2,
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
sx={{
|
||||
bgcolor: 'primary.main',
|
||||
width: 120,
|
||||
height: 120,
|
||||
fontSize: '3rem',
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
{getInitials()}
|
||||
</Avatar>
|
||||
<Typography variant="h5" gutterBottom>
|
||||
{user.name}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||
{user.email}
|
||||
</Typography>
|
||||
{user.preferred_username && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 1 }}
|
||||
>
|
||||
<Badge fontSize="small" />
|
||||
@{user.preferred_username}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
{/* Groups/Roles */}
|
||||
{user.groups && user.groups.length > 0 && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
color="text.secondary"
|
||||
gutterBottom
|
||||
sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}
|
||||
>
|
||||
<Group fontSize="small" />
|
||||
Gruppen
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mt: 1 }}>
|
||||
{user.groups.map((group) => (
|
||||
<Chip key={group} label={group} size="small" color="primary" />
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* Personal Information */}
|
||||
<Grid item xs={12} md={8}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom sx={{ mb: 3 }}>
|
||||
Persönliche Informationen
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Vorname"
|
||||
value={user.given_name || ''}
|
||||
InputProps={{
|
||||
readOnly: true,
|
||||
startAdornment: (
|
||||
<Person sx={{ mr: 1, color: 'text.secondary' }} />
|
||||
),
|
||||
}}
|
||||
variant="outlined"
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Nachname"
|
||||
value={user.family_name || ''}
|
||||
InputProps={{
|
||||
readOnly: true,
|
||||
startAdornment: (
|
||||
<Person sx={{ mr: 1, color: 'text.secondary' }} />
|
||||
),
|
||||
}}
|
||||
variant="outlined"
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="E-Mail-Adresse"
|
||||
value={user.email}
|
||||
InputProps={{
|
||||
readOnly: true,
|
||||
startAdornment: (
|
||||
<Email sx={{ mr: 1, color: 'text.secondary' }} />
|
||||
),
|
||||
}}
|
||||
variant="outlined"
|
||||
helperText="E-Mail-Adresse wird von Authentik verwaltet"
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{user.preferred_username && (
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Benutzername"
|
||||
value={user.preferred_username}
|
||||
InputProps={{
|
||||
readOnly: true,
|
||||
startAdornment: (
|
||||
<Badge sx={{ mr: 1, color: 'text.secondary' }} />
|
||||
),
|
||||
}}
|
||||
variant="outlined"
|
||||
/>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
mt: 3,
|
||||
p: 2,
|
||||
backgroundColor: 'info.lighter',
|
||||
borderRadius: 1,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" color="info.dark">
|
||||
Diese Informationen werden von Authentik verwaltet und können hier nicht
|
||||
bearbeitet werden. Bitte wenden Sie sich an Ihren Administrator, um
|
||||
Änderungen vorzunehmen.
|
||||
</Typography>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Activity Information */}
|
||||
<Card sx={{ mt: 3 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom sx={{ mb: 3 }}>
|
||||
Aktivitätsinformationen
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
p: 2,
|
||||
backgroundColor: 'background.default',
|
||||
borderRadius: 1,
|
||||
}}
|
||||
>
|
||||
<AccessTime color="primary" />
|
||||
<Box>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Letzte Anmeldung
|
||||
</Typography>
|
||||
<Typography variant="body1">
|
||||
{formatDate(new Date())}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* User Preferences */}
|
||||
<Card sx={{ mt: 3 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom sx={{ mb: 3 }}>
|
||||
Benutzereinstellungen
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Kommende Features: Benachrichtigungseinstellungen, Anzeigeoptionen,
|
||||
Spracheinstellungen
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Container>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default Profile;
|
||||
209
frontend/src/pages/Settings.tsx
Normal file
209
frontend/src/pages/Settings.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Container,
|
||||
Typography,
|
||||
Card,
|
||||
CardContent,
|
||||
Grid,
|
||||
FormGroup,
|
||||
FormControlLabel,
|
||||
Switch,
|
||||
Divider,
|
||||
Box,
|
||||
Button,
|
||||
} from '@mui/material';
|
||||
import { Settings as SettingsIcon, Notifications, Palette, Language, Save } from '@mui/icons-material';
|
||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
import { useNotification } from '../contexts/NotificationContext';
|
||||
|
||||
function Settings() {
|
||||
const notification = useNotification();
|
||||
|
||||
// Settings state
|
||||
const [emailNotifications, setEmailNotifications] = useState(true);
|
||||
const [alarmNotifications, setAlarmNotifications] = useState(true);
|
||||
const [maintenanceReminders, setMaintenanceReminders] = useState(false);
|
||||
const [systemNotifications, setSystemNotifications] = useState(true);
|
||||
const [darkMode, setDarkMode] = useState(false);
|
||||
const [compactView, setCompactView] = useState(true);
|
||||
const [animations, setAnimations] = useState(true);
|
||||
|
||||
const handleSaveSettings = () => {
|
||||
try {
|
||||
// In a real application, save settings to backend
|
||||
notification.showSuccess('Einstellungen erfolgreich gespeichert');
|
||||
} catch (error) {
|
||||
notification.showError('Fehler beim Speichern der Einstellungen');
|
||||
}
|
||||
};
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<Container maxWidth="lg">
|
||||
<Typography variant="h4" gutterBottom sx={{ mb: 4 }}>
|
||||
Einstellungen
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
{/* Notification Settings */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
<Notifications color="primary" sx={{ mr: 2 }} />
|
||||
<Typography variant="h6">Benachrichtigungen</Typography>
|
||||
</Box>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
<FormGroup>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={emailNotifications}
|
||||
onChange={(e) => setEmailNotifications(e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label="E-Mail-Benachrichtigungen"
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={alarmNotifications}
|
||||
onChange={(e) => setAlarmNotifications(e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label="Einsatz-Alarme"
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={maintenanceReminders}
|
||||
onChange={(e) => setMaintenanceReminders(e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label="Wartungserinnerungen"
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={systemNotifications}
|
||||
onChange={(e) => setSystemNotifications(e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label="System-Benachrichtigungen"
|
||||
/>
|
||||
</FormGroup>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* Display Settings */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
<Palette color="primary" sx={{ mr: 2 }} />
|
||||
<Typography variant="h6">Anzeigeoptionen</Typography>
|
||||
</Box>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
<FormGroup>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={darkMode}
|
||||
onChange={(e) => {
|
||||
setDarkMode(e.target.checked);
|
||||
notification.showInfo('Dunkler Modus wird in einer zukünftigen Version verfügbar sein');
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label="Dunkler Modus (Vorschau)"
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={compactView}
|
||||
onChange={(e) => setCompactView(e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label="Kompakte Ansicht"
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={animations}
|
||||
onChange={(e) => setAnimations(e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label="Animationen"
|
||||
/>
|
||||
</FormGroup>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* Language Settings */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
<Language color="primary" sx={{ mr: 2 }} />
|
||||
<Typography variant="h6">Sprache</Typography>
|
||||
</Box>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Aktuelle Sprache: Deutsch
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
|
||||
Kommende Features: Sprachauswahl, Datumsformat, Zeitzone
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* General Settings */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
<SettingsIcon color="primary" sx={{ mr: 2 }} />
|
||||
<Typography variant="h6">Allgemein</Typography>
|
||||
</Box>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Kommende Features: Dashboard-Layout, Standardansichten, Exporteinstellungen
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
mt: 3,
|
||||
p: 2,
|
||||
backgroundColor: 'info.lighter',
|
||||
borderRadius: 1,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" color="info.dark">
|
||||
Diese Einstellungen sind derzeit nur zur Demonstration verfügbar. Die Funktionalität
|
||||
wird in zukünftigen Updates implementiert.
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mt: 3, display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
size="large"
|
||||
startIcon={<Save />}
|
||||
onClick={handleSaveSettings}
|
||||
>
|
||||
Einstellungen speichern
|
||||
</Button>
|
||||
</Box>
|
||||
</Container>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default Settings;
|
||||
102
frontend/src/services/api.ts
Normal file
102
frontend/src/services/api.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse, AxiosError } from 'axios';
|
||||
import { API_URL } from '../utils/config';
|
||||
import { getToken, removeToken, removeUser } from '../utils/storage';
|
||||
|
||||
export interface ApiError {
|
||||
message: string;
|
||||
status?: number;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
class ApiService {
|
||||
private axiosInstance: AxiosInstance;
|
||||
|
||||
constructor() {
|
||||
this.axiosInstance = axios.create({
|
||||
baseURL: API_URL,
|
||||
timeout: 30000, // 30 seconds timeout
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Request interceptor: Add Authorization header with JWT
|
||||
this.axiosInstance.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
console.error('Request interceptor error:', error);
|
||||
return Promise.reject(this.handleError(error));
|
||||
}
|
||||
);
|
||||
|
||||
// Response interceptor: Handle errors
|
||||
this.axiosInstance.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error: AxiosError) => {
|
||||
if (error.response?.status === 401) {
|
||||
// Clear tokens and redirect to login
|
||||
console.warn('Unauthorized request, redirecting to login');
|
||||
removeToken();
|
||||
removeUser();
|
||||
window.location.href = '/login';
|
||||
}
|
||||
return Promise.reject(this.handleError(error));
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private handleError(error: AxiosError): ApiError {
|
||||
if (error.response) {
|
||||
// Server responded with error
|
||||
const message = (error.response.data as any)?.message ||
|
||||
(error.response.data as any)?.error ||
|
||||
error.message ||
|
||||
'Ein Fehler ist aufgetreten';
|
||||
return {
|
||||
message,
|
||||
status: error.response.status,
|
||||
code: error.code,
|
||||
};
|
||||
} else if (error.request) {
|
||||
// Request was made but no response received
|
||||
return {
|
||||
message: 'Keine Antwort vom Server. Bitte überprüfen Sie Ihre Internetverbindung.',
|
||||
code: error.code,
|
||||
};
|
||||
} else {
|
||||
// Something else happened
|
||||
return {
|
||||
message: error.message || 'Ein unerwarteter Fehler ist aufgetreten',
|
||||
code: error.code,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async get<T = any>(url: string, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
|
||||
return this.axiosInstance.get<T>(url, config);
|
||||
}
|
||||
|
||||
async post<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
|
||||
return this.axiosInstance.post<T>(url, data, config);
|
||||
}
|
||||
|
||||
async put<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
|
||||
return this.axiosInstance.put<T>(url, data, config);
|
||||
}
|
||||
|
||||
async delete<T = any>(url: string, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
|
||||
return this.axiosInstance.delete<T>(url, config);
|
||||
}
|
||||
|
||||
async patch<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
|
||||
return this.axiosInstance.patch<T>(url, data, config);
|
||||
}
|
||||
}
|
||||
|
||||
export const api = new ApiService();
|
||||
58
frontend/src/services/auth.ts
Normal file
58
frontend/src/services/auth.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { api } from './api';
|
||||
import { AUTHENTIK_URL, CLIENT_ID } from '../utils/config';
|
||||
import { User } from '../types/auth.types';
|
||||
|
||||
const REDIRECT_URI = `${window.location.origin}/auth/callback`;
|
||||
|
||||
export interface AuthCallbackResponse {
|
||||
token: string;
|
||||
user: User;
|
||||
}
|
||||
|
||||
export const authService = {
|
||||
/**
|
||||
* Generate Authentik authorization URL
|
||||
*/
|
||||
getAuthUrl(): string {
|
||||
const params = new URLSearchParams({
|
||||
client_id: CLIENT_ID,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
response_type: 'code',
|
||||
scope: 'openid profile email',
|
||||
});
|
||||
|
||||
return `${AUTHENTIK_URL}/application/o/authorize/?${params.toString()}`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle OAuth callback - send code to backend, receive JWT
|
||||
*/
|
||||
async handleCallback(code: string): Promise<AuthCallbackResponse> {
|
||||
const response = await api.post<AuthCallbackResponse>('/api/auth/callback', {
|
||||
code,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Logout - clear tokens
|
||||
*/
|
||||
async logout(): Promise<void> {
|
||||
try {
|
||||
// Optionally call backend logout endpoint
|
||||
await api.post('/api/auth/logout');
|
||||
} catch (error) {
|
||||
console.error('Error during logout:', error);
|
||||
// Continue with logout even if backend call fails
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get current user information
|
||||
*/
|
||||
async getCurrentUser(): Promise<User> {
|
||||
const response = await api.get<User>('/api/user/me');
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
178
frontend/src/theme/theme.ts
Normal file
178
frontend/src/theme/theme.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { createTheme, ThemeOptions } from '@mui/material/styles';
|
||||
|
||||
// Fire department red color palette
|
||||
const primaryRed = {
|
||||
main: '#d32f2f',
|
||||
light: '#ff6659',
|
||||
dark: '#9a0007',
|
||||
contrastText: '#ffffff',
|
||||
};
|
||||
|
||||
const secondaryBlue = {
|
||||
main: '#1976d2',
|
||||
light: '#63a4ff',
|
||||
dark: '#004ba0',
|
||||
contrastText: '#ffffff',
|
||||
};
|
||||
|
||||
const lightThemeOptions: ThemeOptions = {
|
||||
palette: {
|
||||
mode: 'light',
|
||||
primary: primaryRed,
|
||||
secondary: secondaryBlue,
|
||||
background: {
|
||||
default: '#f5f5f5',
|
||||
paper: '#ffffff',
|
||||
},
|
||||
error: {
|
||||
main: '#f44336',
|
||||
},
|
||||
warning: {
|
||||
main: '#ff9800',
|
||||
},
|
||||
info: {
|
||||
main: '#2196f3',
|
||||
},
|
||||
success: {
|
||||
main: '#4caf50',
|
||||
},
|
||||
},
|
||||
typography: {
|
||||
fontFamily: [
|
||||
'-apple-system',
|
||||
'BlinkMacSystemFont',
|
||||
'"Segoe UI"',
|
||||
'Roboto',
|
||||
'"Helvetica Neue"',
|
||||
'Arial',
|
||||
'sans-serif',
|
||||
].join(','),
|
||||
h1: {
|
||||
fontSize: '2.5rem',
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.2,
|
||||
},
|
||||
h2: {
|
||||
fontSize: '2rem',
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.3,
|
||||
},
|
||||
h3: {
|
||||
fontSize: '1.75rem',
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.4,
|
||||
},
|
||||
h4: {
|
||||
fontSize: '1.5rem',
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.4,
|
||||
},
|
||||
h5: {
|
||||
fontSize: '1.25rem',
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.5,
|
||||
},
|
||||
h6: {
|
||||
fontSize: '1rem',
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.6,
|
||||
},
|
||||
body1: {
|
||||
fontSize: '1rem',
|
||||
lineHeight: 1.5,
|
||||
},
|
||||
body2: {
|
||||
fontSize: '0.875rem',
|
||||
lineHeight: 1.43,
|
||||
},
|
||||
button: {
|
||||
textTransform: 'none',
|
||||
fontWeight: 500,
|
||||
},
|
||||
},
|
||||
spacing: 8,
|
||||
shape: {
|
||||
borderRadius: 8,
|
||||
},
|
||||
components: {
|
||||
MuiButton: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 8,
|
||||
padding: '8px 16px',
|
||||
},
|
||||
contained: {
|
||||
boxShadow: 'none',
|
||||
'&:hover': {
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.2)',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiCard: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 12,
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24)',
|
||||
transition: 'all 0.3s cubic-bezier(.25,.8,.25,1)',
|
||||
'&:hover': {
|
||||
boxShadow: '0 4px 8px rgba(0,0,0,0.16), 0 3px 6px rgba(0,0,0,0.23)',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiPaper: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 8,
|
||||
},
|
||||
elevation1: {
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24)',
|
||||
},
|
||||
elevation2: {
|
||||
boxShadow: '0 3px 6px rgba(0,0,0,0.15), 0 2px 4px rgba(0,0,0,0.12)',
|
||||
},
|
||||
elevation3: {
|
||||
boxShadow: '0 4px 8px rgba(0,0,0,0.16), 0 3px 6px rgba(0,0,0,0.23)',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiAppBar: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const darkThemeOptions: ThemeOptions = {
|
||||
...lightThemeOptions,
|
||||
palette: {
|
||||
mode: 'dark',
|
||||
primary: primaryRed,
|
||||
secondary: secondaryBlue,
|
||||
background: {
|
||||
default: '#121212',
|
||||
paper: '#1e1e1e',
|
||||
},
|
||||
error: {
|
||||
main: '#f44336',
|
||||
},
|
||||
warning: {
|
||||
main: '#ff9800',
|
||||
},
|
||||
info: {
|
||||
main: '#2196f3',
|
||||
},
|
||||
success: {
|
||||
main: '#4caf50',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const lightTheme = createTheme(lightThemeOptions);
|
||||
export const darkTheme = createTheme(darkThemeOptions);
|
||||
|
||||
export default lightTheme;
|
||||
28
frontend/src/types/auth.types.ts
Normal file
28
frontend/src/types/auth.types.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
given_name: string;
|
||||
family_name: string;
|
||||
preferred_username?: string;
|
||||
groups?: string[];
|
||||
}
|
||||
|
||||
export interface AuthTokens {
|
||||
token: string;
|
||||
refreshToken?: string;
|
||||
expiresIn?: number;
|
||||
}
|
||||
|
||||
export interface AuthState {
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export interface AuthContextType extends AuthState {
|
||||
login: (code: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
refreshAuth: () => Promise<void>;
|
||||
}
|
||||
9
frontend/src/utils/config.ts
Normal file
9
frontend/src/utils/config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export const config = {
|
||||
apiUrl: import.meta.env.VITE_API_URL || 'http://localhost:3000',
|
||||
authentikUrl: import.meta.env.VITE_AUTHENTIK_URL || 'https://authentik.yourdomain.com',
|
||||
clientId: import.meta.env.VITE_CLIENT_ID || 'your_client_id_here',
|
||||
};
|
||||
|
||||
export const API_URL = config.apiUrl;
|
||||
export const AUTHENTIK_URL = config.authentikUrl;
|
||||
export const CLIENT_ID = config.clientId;
|
||||
56
frontend/src/utils/storage.ts
Normal file
56
frontend/src/utils/storage.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { User } from '../types/auth.types';
|
||||
|
||||
const TOKEN_KEY = 'auth_token';
|
||||
const USER_KEY = 'auth_user';
|
||||
|
||||
export const getToken = (): string | null => {
|
||||
try {
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
} catch (error) {
|
||||
console.error('Error getting token from localStorage:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const setToken = (token: string): void => {
|
||||
try {
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
} catch (error) {
|
||||
console.error('Error setting token in localStorage:', error);
|
||||
}
|
||||
};
|
||||
|
||||
export const removeToken = (): void => {
|
||||
try {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
} catch (error) {
|
||||
console.error('Error removing token from localStorage:', error);
|
||||
}
|
||||
};
|
||||
|
||||
export const getUser = (): User | null => {
|
||||
try {
|
||||
const userStr = localStorage.getItem(USER_KEY);
|
||||
if (!userStr) return null;
|
||||
return JSON.parse(userStr) as User;
|
||||
} catch (error) {
|
||||
console.error('Error getting user from localStorage:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const setUser = (user: User): void => {
|
||||
try {
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(user));
|
||||
} catch (error) {
|
||||
console.error('Error setting user in localStorage:', error);
|
||||
}
|
||||
};
|
||||
|
||||
export const removeUser = (): void => {
|
||||
try {
|
||||
localStorage.removeItem(USER_KEY);
|
||||
} catch (error) {
|
||||
console.error('Error removing user from localStorage:', error);
|
||||
}
|
||||
};
|
||||
10
frontend/src/vite-env.d.ts
vendored
Normal file
10
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_URL: string;
|
||||
// Add more env variables as needed
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
Reference in New Issue
Block a user