This commit is contained in:
Matthias Hochmeister
2026-02-23 17:08:58 +01:00
commit f09748f4a1
97 changed files with 17729 additions and 0 deletions

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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';