inital
This commit is contained in:
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';
|
||||
Reference in New Issue
Block a user