feat(frontend): implement unified design system with 17 reusable template components, skeleton loading states, and golden-ratio-based layouts

This commit is contained in:
Matthias Hochmeister
2026-04-13 10:43:27 +02:00
parent 5acfd7cc4f
commit 43ce1f930c
69 changed files with 3289 additions and 3115 deletions

View File

@@ -1,13 +1,13 @@
import { useState, useCallback } from 'react';
import {
Box, Paper, Typography, TextField, Button, Alert,
Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions,
CircularProgress, Divider,
} from '@mui/material';
import DeleteSweepIcon from '@mui/icons-material/DeleteSweep';
import RestartAltIcon from '@mui/icons-material/RestartAlt';
import { api } from '../../services/api';
import { useNotification } from '../../contexts/NotificationContext';
import { ConfirmDialog } from '../templates';
interface CleanupSection {
key: string;
@@ -193,31 +193,21 @@ export default function DataManagementTab() {
);
})}
<Dialog open={!!confirmDialog} onClose={() => !deleting && setConfirmDialog(null)}>
<DialogTitle>Daten loeschen?</DialogTitle>
<DialogContent>
<DialogContentText>
{confirmDialog && (
<>
<strong>{confirmDialog.count}</strong> {confirmDialog.count === 1 ? 'Eintrag' : 'Eintraege'} aus <strong>{confirmDialog.label}</strong> werden
unwiderruflich geloescht. Dieser Vorgang kann nicht rueckgaengig gemacht werden.
</>
)}
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setConfirmDialog(null)} disabled={deleting}>Abbrechen</Button>
<Button
onClick={handleDelete}
color="error"
variant="contained"
disabled={deleting}
startIcon={deleting ? <CircularProgress size={16} /> : <DeleteSweepIcon />}
>
{deleting ? 'Wird geloescht...' : 'Endgueltig loeschen'}
</Button>
</DialogActions>
</Dialog>
<ConfirmDialog
open={!!confirmDialog}
onClose={() => setConfirmDialog(null)}
onConfirm={handleDelete}
title="Daten loeschen?"
message={confirmDialog ? (
<>
<strong>{confirmDialog.count}</strong> {confirmDialog.count === 1 ? 'Eintrag' : 'Eintraege'} aus <strong>{confirmDialog.label}</strong> werden
unwiderruflich geloescht. Dieser Vorgang kann nicht rueckgaengig gemacht werden.
</>
) : ''}
confirmLabel="Endgueltig loeschen"
confirmColor="error"
isLoading={deleting}
/>
{/* ---- Reset / Truncate sections ---- */}
<Divider sx={{ my: 4 }} />
@@ -268,31 +258,21 @@ export default function DataManagementTab() {
);
})}
<Dialog open={!!resetConfirmDialog} onClose={() => !resetDeleting && setResetConfirmDialog(null)}>
<DialogTitle>Daten zuruecksetzen?</DialogTitle>
<DialogContent>
<DialogContentText>
{resetConfirmDialog && (
<>
<strong>{resetConfirmDialog.count}</strong> {resetConfirmDialog.count === 1 ? 'Eintrag' : 'Eintraege'} aus <strong>{resetConfirmDialog.label}</strong> werden
unwiderruflich geloescht und die Nummerierung auf 1 zurueckgesetzt. Dieser Vorgang kann nicht rueckgaengig gemacht werden.
</>
)}
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setResetConfirmDialog(null)} disabled={resetDeleting}>Abbrechen</Button>
<Button
onClick={handleResetDelete}
color="error"
variant="contained"
disabled={resetDeleting}
startIcon={resetDeleting ? <CircularProgress size={16} /> : <RestartAltIcon />}
>
{resetDeleting ? 'Wird zurueckgesetzt...' : 'Endgueltig zuruecksetzen'}
</Button>
</DialogActions>
</Dialog>
<ConfirmDialog
open={!!resetConfirmDialog}
onClose={() => setResetConfirmDialog(null)}
onConfirm={handleResetDelete}
title="Daten zuruecksetzen?"
message={resetConfirmDialog ? (
<>
<strong>{resetConfirmDialog.count}</strong> {resetConfirmDialog.count === 1 ? 'Eintrag' : 'Eintraege'} aus <strong>{resetConfirmDialog.label}</strong> werden
unwiderruflich geloescht und die Nummerierung auf 1 zurueckgesetzt. Dieser Vorgang kann nicht rueckgaengig gemacht werden.
</>
) : ''}
confirmLabel="Endgueltig zuruecksetzen"
confirmColor="error"
isLoading={resetDeleting}
/>
</Box>
);
}

View File

@@ -1,10 +1,12 @@
import { Card, CardContent, Typography, Box, Chip } from '@mui/material';
import { Typography, Box, Chip } from '@mui/material';
import { useQuery } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { MonitorHeartOutlined } from '@mui/icons-material';
import { adminApi } from '../../services/admin';
import { useCountUp } from '../../hooks/useCountUp';
import { usePermissionContext } from '../../contexts/PermissionContext';
import { WidgetCard } from '../templates/WidgetCard';
import { StatSkeleton } from '../templates/SkeletonPresets';
function AdminStatusWidget() {
const { hasPermission } = usePermissionContext();
@@ -29,38 +31,32 @@ function AdminStatusWidget() {
const color = allUp ? 'success' : majorityDown ? 'error' : 'warning';
return (
<Card
sx={{ cursor: 'pointer', '&:hover': { boxShadow: 4 } }}
<WidgetCard
title="Service Status"
icon={<MonitorHeartOutlined color={color} />}
isLoading={!data}
skeleton={<StatSkeleton />}
onClick={() => navigate('/admin')}
>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<MonitorHeartOutlined color={color} />
<Typography variant="h6" component="div">
Service Status
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.5, mb: 1 }}>
<Typography variant="h3" component="span" sx={{ fontWeight: 700 }}>
{up}
</Typography>
<Typography variant="h5" component="span" color="text.secondary">
/ {total}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ ml: 0.5 }}>
Services online
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.5, mb: 1 }}>
<Typography variant="h3" component="span" sx={{ fontWeight: 700 }}>
{up}
</Typography>
<Typography variant="h5" component="span" color="text.secondary">
/ {total}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ ml: 0.5 }}>
Services online
</Typography>
</Box>
<Chip
label={allUp ? 'Alle aktiv' : majorityDown ? 'Kritisch' : 'Teilweise gestört'}
color={color}
size="small"
variant="outlined"
/>
</CardContent>
</Card>
<Chip
label={allUp ? 'Alle aktiv' : majorityDown ? 'Kritisch' : 'Teilweise gestört'}
color={color}
size="small"
variant="outlined"
/>
</WidgetCard>
);
}

View File

@@ -1,9 +1,11 @@
import { Card, CardContent, Typography, Box, Chip, Skeleton } from '@mui/material';
import { Box, Chip } from '@mui/material';
import { Build } from '@mui/icons-material';
import { useQuery } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { ausruestungsanfrageApi } from '../../services/ausruestungsanfrage';
import type { AusruestungWidgetOverview } from '../../types/ausruestungsanfrage.types';
import { WidgetCard } from '../templates/WidgetCard';
import { ChipListSkeleton } from '../templates/SkeletonPresets';
function AusruestungsanfrageWidget() {
const navigate = useNavigate();
@@ -15,59 +17,32 @@ function AusruestungsanfrageWidget() {
retry: 1,
});
if (isLoading) {
return (
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>Interne Bestellungen</Typography>
<Skeleton variant="rectangular" height={60} />
</CardContent>
</Card>
);
}
if (isError || !overview) {
return (
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>Interne Bestellungen</Typography>
<Typography variant="body2" color="text.secondary">
Daten konnten nicht geladen werden.
</Typography>
</CardContent>
</Card>
);
}
const hasAny = overview.total_count > 0;
const hasAny = (overview?.total_count ?? 0) > 0;
return (
<Card
sx={{ cursor: 'pointer', '&:hover': { bgcolor: 'action.hover' } }}
<WidgetCard
title="Interne Bestellungen"
icon={<Build fontSize="small" color="action" />}
isLoading={isLoading}
skeleton={<ChipListSkeleton />}
isError={isError || (!isLoading && !overview)}
errorMessage="Daten konnten nicht geladen werden."
isEmpty={!isLoading && !isError && !!overview && !hasAny}
emptyMessage="Keine Anfragen vorhanden."
onClick={() => navigate('/ausruestungsanfrage')}
>
<CardContent>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1.5 }}>
<Typography variant="h6">Interne Bestellungen</Typography>
<Build fontSize="small" color="action" />
</Box>
{!hasAny ? (
<Typography variant="body2" color="text.secondary">Keine Anfragen vorhanden.</Typography>
) : (
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
{overview.unhandled_count > 0 && (
<Chip label={`${overview.unhandled_count} Neu`} size="small" color="error" variant="outlined" />
)}
{overview.pending_count > 0 && (
<Chip label={`${overview.pending_count} Offen`} size="small" color="warning" variant="outlined" />
)}
{overview.approved_count > 0 && (
<Chip label={`${overview.approved_count} Genehmigt`} size="small" color="info" variant="outlined" />
)}
</Box>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
{overview && overview.unhandled_count > 0 && (
<Chip label={`${overview.unhandled_count} Neu`} size="small" color="error" variant="outlined" />
)}
</CardContent>
</Card>
{overview && overview.pending_count > 0 && (
<Chip label={`${overview.pending_count} Offen`} size="small" color="warning" variant="outlined" />
)}
{overview && overview.approved_count > 0 && (
<Chip label={`${overview.approved_count} Genehmigt`} size="small" color="info" variant="outlined" />
)}
</Box>
</WidgetCard>
);
}

View File

@@ -1,8 +1,9 @@
import { Card, CardContent, Typography, Box } from '@mui/material';
import { Typography, Box } from '@mui/material';
import { Campaign } from '@mui/icons-material';
import { useQuery } from '@tanstack/react-query';
import { bannerApi } from '../../services/banners';
import type { BannerLevel } from '../../types/banner.types';
import { WidgetCard } from '../templates/WidgetCard';
const SEVERITY_COLOR: Record<BannerLevel, string> = {
info: '#1976d2',
@@ -23,29 +24,26 @@ export default function BannerWidget() {
if (widgetBanners.length === 0) return null;
return (
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<Campaign color="primary" />
<Typography variant="h6">Mitteilungen</Typography>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
{widgetBanners.map(banner => (
<Box
key={banner.id}
sx={{
borderLeft: `4px solid ${SEVERITY_COLOR[banner.level]}`,
pl: 2,
py: 1,
}}
>
<Typography variant="body2">
{banner.message}
</Typography>
</Box>
))}
</Box>
</CardContent>
</Card>
<WidgetCard
title="Mitteilungen"
icon={<Campaign color="primary" />}
>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
{widgetBanners.map(banner => (
<Box
key={banner.id}
sx={{
borderLeft: `4px solid ${SEVERITY_COLOR[banner.level]}`,
pl: 2,
py: 1,
}}
>
<Typography variant="body2">
{banner.message}
</Typography>
</Box>
))}
</Box>
</WidgetCard>
);
}

View File

@@ -1,10 +1,12 @@
import { Card, CardContent, Typography, Box, Chip, Skeleton } from '@mui/material';
import { Box, Chip } from '@mui/material';
import { LocalShipping } from '@mui/icons-material';
import { useQuery } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { bestellungApi } from '../../services/bestellung';
import { BESTELLUNG_STATUS_LABELS, BESTELLUNG_STATUS_COLORS } from '../../types/bestellung.types';
import type { BestellungStatus } from '../../types/bestellung.types';
import { WidgetCard } from '../templates/WidgetCard';
import { ChipListSkeleton } from '../templates/SkeletonPresets';
function BestellungenWidget() {
const navigate = useNavigate();
@@ -26,54 +28,30 @@ function BestellungenWidget() {
.map((s) => ({ status: s, count: openOrders.filter((o) => o.status === s).length }))
.filter((s) => s.count > 0);
if (isLoading) {
return (
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>Bestellungen</Typography>
<Skeleton variant="rectangular" height={40} />
</CardContent>
</Card>
);
}
if (isError) {
return (
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>Bestellungen</Typography>
<Typography variant="body2" color="text.secondary">
Bestellungen konnten nicht geladen werden.
</Typography>
</CardContent>
</Card>
);
}
return (
<Card sx={{ cursor: 'pointer', '&:hover': { bgcolor: 'action.hover' } }} onClick={() => navigate('/bestellungen')}>
<CardContent>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1.5 }}>
<Typography variant="h6">Bestellungen</Typography>
<LocalShipping fontSize="small" color="action" />
</Box>
{statusCounts.length === 0 ? (
<Typography variant="body2" color="text.secondary">Keine offenen Bestellungen</Typography>
) : (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{statusCounts.map(({ status, count }) => (
<Chip
key={status}
label={`${count} ${BESTELLUNG_STATUS_LABELS[status]}`}
color={BESTELLUNG_STATUS_COLORS[status]}
size="small"
variant="outlined"
/>
))}
</Box>
)}
</CardContent>
</Card>
<WidgetCard
title="Bestellungen"
icon={<LocalShipping fontSize="small" color="action" />}
isLoading={isLoading}
skeleton={<ChipListSkeleton />}
isError={isError}
errorMessage="Bestellungen konnten nicht geladen werden."
isEmpty={!isLoading && !isError && statusCounts.length === 0}
emptyMessage="Keine offenen Bestellungen"
onClick={() => navigate('/bestellungen')}
>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{statusCounts.map(({ status, count }) => (
<Chip
key={status}
label={`${count} ${BESTELLUNG_STATUS_LABELS[status]}`}
color={BESTELLUNG_STATUS_COLORS[status]}
size="small"
variant="outlined"
/>
))}
</Box>
</WidgetCard>
);
}

View File

@@ -1,10 +1,7 @@
import React from 'react';
import {
Card,
CardContent,
Typography,
Box,
Divider,
Skeleton,
} from '@mui/material';
import { MenuBook } from '@mui/icons-material';
@@ -14,11 +11,10 @@ import { de } from 'date-fns/locale';
import { bookstackApi } from '../../services/bookstack';
import type { BookStackPage } from '../../types/bookstack.types';
import { safeOpenUrl } from '../../utils/safeOpenUrl';
import { ListCard } from '../templates/ListCard';
import { WidgetCard } from '../templates/WidgetCard';
const PageRow: React.FC<{ page: BookStackPage; showDivider: boolean }> = ({
page,
showDivider,
}) => {
const PageRow: React.FC<{ page: BookStackPage }> = ({ page }) => {
const handleClick = () => {
safeOpenUrl(page.url);
};
@@ -28,43 +24,39 @@ const PageRow: React.FC<{ page: BookStackPage; showDivider: boolean }> = ({
: null;
return (
<>
<Box
onClick={handleClick}
sx={{
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
py: 1.5,
px: 1,
cursor: 'pointer',
borderRadius: 1,
transition: 'background-color 0.15s ease',
'&:hover': { bgcolor: 'action.hover' },
}}
>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="subtitle2" noWrap>
{page.name}
</Typography>
{page.book && (
<Typography variant="body2" color="text.secondary" noWrap sx={{ mt: 0.25 }}>
{page.book.name}
</Typography>
)}
</Box>
{relativeTime && (
<Typography
variant="caption"
color="text.secondary"
sx={{ ml: 1, whiteSpace: 'nowrap', mt: 0.25 }}
>
{relativeTime}
<Box
onClick={handleClick}
sx={{
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
px: 1,
cursor: 'pointer',
borderRadius: 1,
transition: 'background-color 0.15s ease',
'&:hover': { bgcolor: 'action.hover' },
}}
>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="subtitle2" noWrap>
{page.name}
</Typography>
{page.book && (
<Typography variant="body2" color="text.secondary" noWrap sx={{ mt: 0.25 }}>
{page.book.name}
</Typography>
)}
</Box>
{showDivider && <Divider />}
</>
{relativeTime && (
<Typography
variant="caption"
color="text.secondary"
sx={{ ml: 1, whiteSpace: 'nowrap', mt: 0.25 }}
>
{relativeTime}
</Typography>
)}
</Box>
);
};
@@ -80,77 +72,44 @@ const BookStackRecentWidget: React.FC = () => {
const pages = (data?.data ?? []).slice(0, 5);
// Only show "nicht eingerichtet" when we got a successful response with configured=false
// (not when the request errored out with 403 etc.)
if (data && !configured) {
return (
<Card sx={{ height: '100%' }}>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<MenuBook color="disabled" />
<Typography variant="h6" sx={{ flexGrow: 1 }} color="text.secondary">
Wissen Neueste Seiten
</Typography>
</Box>
<Typography variant="body2" color="text.secondary" sx={{ py: 2, textAlign: 'center' }}>
BookStack nicht eingerichtet
</Typography>
</CardContent>
</Card>
<WidgetCard
title="Wissen — Neueste Seiten"
icon={<MenuBook color="disabled" />}
isEmpty
emptyMessage="BookStack nicht eingerichtet"
/>
);
}
if (isError) {
return (
<WidgetCard
title="Wissen — Neueste Seiten"
icon={<MenuBook color="primary" />}
isError
errorMessage="BookStack nicht erreichbar"
/>
);
}
return (
<Card
sx={{
height: '100%',
transition: 'all 0.3s ease',
'&:hover': { boxShadow: 3 },
}}
>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<MenuBook color="primary" />
<Typography variant="h6" sx={{ flexGrow: 1 }}>
Wissen Neueste Seiten
</Typography>
<ListCard
title="Wissen — Neueste Seiten"
icon={<MenuBook color="primary" />}
items={pages}
renderItem={(page) => <PageRow key={page.id} page={page} />}
isLoading={isLoading}
skeletonCount={5}
skeletonItem={
<Box sx={{ mb: 0.5 }}>
<Skeleton variant="text" width="70%" height={22} animation="wave" />
<Skeleton variant="text" width="50%" height={18} animation="wave" />
</Box>
{isLoading && (
<Box>
{[1, 2, 3, 4, 5].map((n) => (
<Box key={n} sx={{ mb: 1.5 }}>
<Skeleton variant="text" width="70%" height={22} />
<Skeleton variant="text" width="50%" height={18} />
</Box>
))}
</Box>
)}
{isError && (
<Typography variant="body2" color="text.secondary" sx={{ py: 2, textAlign: 'center' }}>
BookStack nicht erreichbar
</Typography>
)}
{!isLoading && !isError && pages.length === 0 && (
<Typography variant="body2" color="text.secondary" sx={{ py: 2, textAlign: 'center' }}>
Keine Seiten gefunden
</Typography>
)}
{!isLoading && !isError && pages.length > 0 && (
<Box>
{pages.map((page, index) => (
<PageRow
key={page.id}
page={page}
showDivider={index < pages.length - 1}
/>
))}
</Box>
)}
</CardContent>
</Card>
}
emptyMessage="Keine Seiten gefunden"
/>
);
};

View File

@@ -1,11 +1,8 @@
import React, { useState, useEffect, useRef } from 'react';
import {
Card,
CardContent,
Typography,
Box,
TextField,
Divider,
CircularProgress,
InputAdornment,
Skeleton,
@@ -15,6 +12,7 @@ import { useQuery } from '@tanstack/react-query';
import { bookstackApi } from '../../services/bookstack';
import type { BookStackSearchResult } from '../../types/bookstack.types';
import { safeOpenUrl } from '../../utils/safeOpenUrl';
import { WidgetCard } from '../templates/WidgetCard';
function stripHtml(html: string): string {
return html.replace(/<[^>]*>/g, '').trim();
@@ -22,7 +20,7 @@ function stripHtml(html: string): string {
const ResultRow: React.FC<{ result: BookStackSearchResult; showDivider: boolean }> = ({
result,
showDivider,
showDivider: _showDivider,
}) => {
const preview = result.preview_html?.content ? stripHtml(result.preview_html.content) : '';
@@ -58,7 +56,6 @@ const ResultRow: React.FC<{ result: BookStackSearchResult; showDivider: boolean
</Typography>
)}
</Box>
{showDivider && <Divider />}
</>
);
};
@@ -123,86 +120,64 @@ const BookStackSearchWidget: React.FC = () => {
if (configured === undefined) {
return (
<Card sx={{ height: '100%' }}>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<MenuBook color="primary" />
<Skeleton variant="text" width={160} height={32} />
</Box>
<Skeleton variant="rectangular" height={40} sx={{ borderRadius: 1 }} />
</CardContent>
</Card>
<WidgetCard
title="Wissen — Suche"
icon={<MenuBook color="primary" />}
isLoading
skeleton={<Skeleton variant="rectangular" height={40} sx={{ borderRadius: 1 }} animation="wave" />}
/>
);
}
if (configured === false) {
return (
<Card sx={{ height: '100%' }}>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<MenuBook color="disabled" />
<Typography variant="h6" sx={{ flexGrow: 1 }} color="text.secondary">
Wissen Suche
</Typography>
</Box>
<Typography variant="body2" color="text.secondary" sx={{ py: 2, textAlign: 'center' }}>
BookStack nicht eingerichtet
</Typography>
</CardContent>
</Card>
<WidgetCard
title="Wissen — Suche"
icon={<MenuBook color="disabled" />}
isEmpty
emptyMessage="BookStack nicht eingerichtet"
/>
);
}
return (
<Card
sx={{
height: '100%',
transition: 'all 0.3s ease',
'&:hover': { boxShadow: 3 },
}}
<WidgetCard
title="Wissen — Suche"
icon={<MenuBook color="primary" />}
>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<MenuBook color="primary" />
<Typography variant="h6" sx={{ flexGrow: 1 }}>
Wissen Suche
</Typography>
<TextField
fullWidth
size="small"
placeholder="Suchbegriff eingeben..."
value={query}
onChange={(e) => setQuery(e.target.value)}
InputProps={{
startAdornment: (
<InputAdornment position="start">
{searching ? <CircularProgress size={16} /> : <Search fontSize="small" />}
</InputAdornment>
),
}}
/>
{!searching && query.trim() && results.length === 0 && (
<Typography variant="body2" color="text.secondary" sx={{ py: 2, textAlign: 'center' }}>
Keine Ergebnisse für \u201e{query}\u201c
</Typography>
)}
{results.length > 0 && (
<Box sx={{ mt: 1, maxHeight: 300, overflow: 'auto' }}>
{results.map((result, index) => (
<ResultRow
key={result.id}
result={result}
showDivider={index < results.length - 1}
/>
))}
</Box>
<TextField
fullWidth
size="small"
placeholder="Suchbegriff eingeben..."
value={query}
onChange={(e) => setQuery(e.target.value)}
InputProps={{
startAdornment: (
<InputAdornment position="start">
{searching ? <CircularProgress size={16} /> : <Search fontSize="small" />}
</InputAdornment>
),
}}
/>
{!searching && query.trim() && results.length === 0 && (
<Typography variant="body2" color="text.secondary" sx={{ py: 2, textAlign: 'center' }}>
Keine Ergebnisse für {query}"
</Typography>
)}
{results.length > 0 && (
<Box sx={{ mt: 1, maxHeight: 300, overflow: 'auto' }}>
{results.map((result, index) => (
<ResultRow
key={result.id}
result={result}
showDivider={index < results.length - 1}
/>
))}
</Box>
)}
</CardContent>
</Card>
)}
</WidgetCard>
);
};

View File

@@ -1,8 +1,10 @@
import { Card, CardContent, Typography, Box, Chip, Skeleton } from '@mui/material';
import { Typography, Box, Chip } from '@mui/material';
import { AccountBalance } from '@mui/icons-material';
import { useQuery } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { buchhaltungApi } from '../../services/buchhaltung';
import { WidgetCard } from '../templates/WidgetCard';
import { ChipListSkeleton } from '../templates/SkeletonPresets';
function fmtEur(val: number) {
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(val);
@@ -30,76 +32,40 @@ function BuchhaltungWidget() {
const isLoading = loadingJahre || (!!activeJahr && loadingStats);
if (isLoading) {
return (
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>Buchhaltung</Typography>
<Skeleton variant="rectangular" height={60} />
</CardContent>
</Card>
);
}
if (!activeJahr) {
return (
<Card sx={{ cursor: 'pointer', '&:hover': { bgcolor: 'action.hover' } }} onClick={() => navigate('/buchhaltung')}>
<CardContent>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
<Typography variant="h6">Buchhaltung</Typography>
<AccountBalance fontSize="small" color="action" />
</Box>
<Typography variant="body2" color="text.secondary">Kein aktives Haushaltsjahr</Typography>
</CardContent>
</Card>
);
}
if (isError || !stats) {
return (
<Card sx={{ cursor: 'pointer', '&:hover': { bgcolor: 'action.hover' } }} onClick={() => navigate('/buchhaltung')}>
<CardContent>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
<Typography variant="h6">Buchhaltung</Typography>
<AccountBalance fontSize="small" color="action" />
</Box>
<Typography variant="body2" color="text.secondary">Daten konnten nicht geladen werden</Typography>
</CardContent>
</Card>
);
}
const overBudgetCount = stats.konten_budget.filter(k => k.auslastung_prozent >= 80).length;
const overBudgetCount = stats ? stats.konten_budget.filter(k => k.auslastung_prozent >= 80).length : 0;
return (
<Card sx={{ cursor: 'pointer', '&:hover': { bgcolor: 'action.hover' } }} onClick={() => navigate('/buchhaltung')}>
<CardContent>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1.5 }}>
<Typography variant="h6">Buchhaltung</Typography>
<AccountBalance fontSize="small" color="action" />
</Box>
<Box sx={{ mb: 0.5 }}>
<Typography variant="body2" color="text.secondary" component="span">Einnahmen: </Typography>
<Typography variant="body2" color="success.main" component="span" fontWeight={600}>{fmtEur(stats.total_einnahmen)}</Typography>
</Box>
<Box sx={{ mb: 0.5 }}>
<Typography variant="body2" color="text.secondary" component="span">Ausgaben: </Typography>
<Typography variant="body2" color="error.main" component="span" fontWeight={600}>{fmtEur(stats.total_ausgaben)}</Typography>
</Box>
<Box sx={{ mb: overBudgetCount > 0 ? 1 : 0 }}>
<Typography variant="body2" color="text.secondary" component="span">Saldo: </Typography>
<Typography variant="body2" color={stats.saldo >= 0 ? 'success.main' : 'error.main'} component="span" fontWeight={600}>{fmtEur(stats.saldo)}</Typography>
</Box>
{overBudgetCount > 0 && (
<Chip
label={`${overBudgetCount} Konto${overBudgetCount > 1 ? 'n' : ''} über 80% Budget`}
color="warning"
size="small"
variant="outlined"
/>
)}
</CardContent>
</Card>
<WidgetCard
title="Buchhaltung"
icon={<AccountBalance fontSize="small" color="action" />}
isLoading={isLoading}
skeleton={<ChipListSkeleton />}
isError={isError || (!isLoading && !activeJahr)}
errorMessage={!activeJahr ? 'Kein aktives Haushaltsjahr' : 'Daten konnten nicht geladen werden'}
isEmpty={!isLoading && !isError && !!activeJahr && !stats}
onClick={() => navigate('/buchhaltung')}
>
<Box sx={{ mb: 0.5 }}>
<Typography variant="body2" color="text.secondary" component="span">Einnahmen: </Typography>
<Typography variant="body2" color="success.main" component="span" fontWeight={600}>{fmtEur(stats?.total_einnahmen ?? 0)}</Typography>
</Box>
<Box sx={{ mb: 0.5 }}>
<Typography variant="body2" color="text.secondary" component="span">Ausgaben: </Typography>
<Typography variant="body2" color="error.main" component="span" fontWeight={600}>{fmtEur(stats?.total_ausgaben ?? 0)}</Typography>
</Box>
<Box sx={{ mb: overBudgetCount > 0 ? 1 : 0 }}>
<Typography variant="body2" color="text.secondary" component="span">Saldo: </Typography>
<Typography variant="body2" color={(stats?.saldo ?? 0) >= 0 ? 'success.main' : 'error.main'} component="span" fontWeight={600}>{fmtEur(stats?.saldo ?? 0)}</Typography>
</Box>
{overBudgetCount > 0 && (
<Chip
label={`${overBudgetCount} Konto${overBudgetCount > 1 ? 'n' : ''} über 80% Budget`}
color="warning"
size="small"
variant="outlined"
/>
)}
</WidgetCard>
);
}

View File

@@ -1,8 +1,10 @@
import { Card, CardContent, Typography, Box, Chip, Skeleton } from '@mui/material';
import { Typography, Box, Chip } from '@mui/material';
import { AssignmentTurnedIn, Warning } from '@mui/icons-material';
import { useQuery } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { checklistenApi } from '../../services/checklisten';
import { WidgetCard } from '../templates/WidgetCard';
import { ChipListSkeleton } from '../templates/SkeletonPresets';
function ChecklistWidget() {
const navigate = useNavigate();
@@ -16,77 +18,48 @@ function ChecklistWidget() {
const overdueItems = overdue ?? [];
if (isLoading) {
return (
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>Checklisten</Typography>
<Skeleton variant="rectangular" height={40} />
</CardContent>
</Card>
);
}
if (isError) {
return (
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>Checklisten</Typography>
<Typography variant="body2" color="text.secondary">
Checklisten konnten nicht geladen werden.
</Typography>
</CardContent>
</Card>
);
}
const titleAction = !isLoading && !isError && overdueItems.length > 0 ? (
<Chip icon={<Warning />} label={overdueItems.length} color="error" size="small" />
) : undefined;
return (
<Card sx={{ cursor: 'pointer', '&:hover': { bgcolor: 'action.hover' } }} onClick={() => navigate('/checklisten')}>
<CardContent>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="h6">Checklisten</Typography>
{overdueItems.length > 0 && (
<Chip
icon={<Warning />}
label={overdueItems.length}
color="error"
size="small"
/>
)}
</Box>
<AssignmentTurnedIn fontSize="small" color="action" />
</Box>
{overdueItems.length === 0 ? (
<Typography variant="body2" color="text.secondary">Alle Checklisten aktuell</Typography>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{overdueItems.slice(0, 5).map((item) => {
const days = Math.ceil((Date.now() - new Date(item.naechste_faellig_am).getTime()) / 86400000);
const targetName = item.fahrzeug_name || item.ausruestung_name || '';
return (
<Box key={`${item.fahrzeug_id || item.ausruestung_id}-${item.vorlage_id}`} sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="body2" noWrap sx={{ maxWidth: '60%' }}>
{targetName}
</Typography>
<Chip
label={`${item.vorlage_name} \u2013 ${days > 0 ? `${days}d` : 'heute'}`}
color={days > 7 ? 'error' : days > 0 ? 'warning' : 'info'}
size="small"
variant="outlined"
/>
</Box>
);
})}
{overdueItems.length > 5 && (
<Typography variant="caption" color="text.secondary">
+ {overdueItems.length - 5} weitere
<WidgetCard
title="Checklisten"
icon={<AssignmentTurnedIn fontSize="small" color="action" />}
action={titleAction}
isLoading={isLoading}
skeleton={<ChipListSkeleton />}
isError={isError}
errorMessage="Checklisten konnten nicht geladen werden."
isEmpty={!isLoading && !isError && overdueItems.length === 0}
emptyMessage="Alle Checklisten aktuell"
onClick={() => navigate('/checklisten')}
>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{overdueItems.slice(0, 5).map((item) => {
const days = Math.ceil((Date.now() - new Date(item.naechste_faellig_am).getTime()) / 86400000);
const targetName = item.fahrzeug_name || item.ausruestung_name || '';
return (
<Box key={`${item.fahrzeug_id || item.ausruestung_id}-${item.vorlage_id}`} sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="body2" noWrap sx={{ maxWidth: '60%' }}>
{targetName}
</Typography>
)}
</Box>
<Chip
label={`${item.vorlage_name} \u2013 ${days > 0 ? `${days}d` : 'heute'}`}
color={days > 7 ? 'error' : days > 0 ? 'warning' : 'info'}
size="small"
variant="outlined"
/>
</Box>
);
})}
{overdueItems.length > 5 && (
<Typography variant="caption" color="text.secondary">
+ {overdueItems.length - 5} weitere
</Typography>
)}
</CardContent>
</Card>
</Box>
</WidgetCard>
);
}

View File

@@ -1,13 +1,9 @@
import React, { useState } from 'react';
import {
Card,
CardContent,
Typography,
Box,
TextField,
Button,
Switch,
FormControlLabel,
Typography,
} from '@mui/material';
import { CalendarMonth } from '@mui/icons-material';
import { useMutation, useQueryClient } from '@tanstack/react-query';
@@ -15,6 +11,7 @@ import { eventsApi } from '../../services/events';
import type { CreateVeranstaltungInput } from '../../types/events.types';
import { useNotification } from '../../contexts/NotificationContext';
import { usePermissionContext } from '../../contexts/PermissionContext';
import { FormCard } from '../templates/FormCard';
function toDatetimeLocal(date: Date): string {
const pad = (n: number) => String(n).padStart(2, '0');
@@ -112,93 +109,74 @@ const EventQuickAddWidget: React.FC = () => {
const datumBisValue = ganztaegig ? datumBis.slice(0, 10) : datumBis;
return (
<Card
sx={{
height: '100%',
transition: 'all 0.3s ease',
'&:hover': { boxShadow: 3 },
}}
<FormCard
title="Veranstaltung"
icon={<CalendarMonth color="primary" />}
isSubmitting={mutation.isPending}
onSubmit={handleSubmit}
submitLabel="Erstellen"
>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<CalendarMonth color="primary" />
<Typography variant="h6">Veranstaltung</Typography>
</Box>
<TextField
fullWidth
size="small"
label="Titel"
value={titel}
onChange={(e) => setTitel(e.target.value)}
required
inputProps={{ maxLength: 250 }}
/>
<Box component="form" onSubmit={handleSubmit} sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<TextField
fullWidth
size="small"
label="Titel"
value={titel}
onChange={(e) => setTitel(e.target.value)}
required
inputProps={{ maxLength: 250 }}
/>
<FormControlLabel
control={
<Switch
checked={ganztaegig}
onChange={(e) => setGanztaegig(e.target.checked)}
size="small"
/>
}
label={<Typography variant="body2">Ganztägig</Typography>}
sx={{ mx: 0 }}
/>
<FormControlLabel
control={
<Switch
checked={ganztaegig}
onChange={(e) => setGanztaegig(e.target.checked)}
size="small"
/>
}
label={<Typography variant="body2">Ganztägig</Typography>}
sx={{ mx: 0 }}
/>
<TextField
fullWidth
size="small"
label="Datum von"
type={dateFieldType}
value={datumVonValue}
onChange={(e) => {
const val = e.target.value;
setDatumVon(ganztaegig ? val + 'T00:00' : val);
}}
required
InputLabelProps={{ shrink: true }}
/>
<TextField
fullWidth
size="small"
label="Datum von"
type={dateFieldType}
value={datumVonValue}
onChange={(e) => {
const val = e.target.value;
setDatumVon(ganztaegig ? val + 'T00:00' : val);
}}
required
InputLabelProps={{ shrink: true }}
/>
<TextField
fullWidth
size="small"
label="Datum bis"
type={dateFieldType}
value={datumBisValue}
onChange={(e) => {
const val = e.target.value;
setDatumBis(ganztaegig ? val + 'T00:00' : val);
}}
required
InputLabelProps={{ shrink: true }}
/>
<TextField
fullWidth
size="small"
label="Datum bis"
type={dateFieldType}
value={datumBisValue}
onChange={(e) => {
const val = e.target.value;
setDatumBis(ganztaegig ? val + 'T00:00' : val);
}}
required
InputLabelProps={{ shrink: true }}
/>
<TextField
fullWidth
size="small"
label="Beschreibung (optional)"
value={beschreibung}
onChange={(e) => setBeschreibung(e.target.value)}
multiline
rows={2}
inputProps={{ maxLength: 1000 }}
/>
<Button
type="submit"
variant="contained"
size="small"
disabled={!titel.trim() || !datumVon || !datumBis || mutation.isPending}
fullWidth
>
{mutation.isPending ? 'Wird erstellt…' : 'Erstellen'}
</Button>
</Box>
</CardContent>
</Card>
<TextField
fullWidth
size="small"
label="Beschreibung (optional)"
value={beschreibung}
onChange={(e) => setBeschreibung(e.target.value)}
multiline
rows={2}
inputProps={{ maxLength: 1000 }}
/>
</FormCard>
);
};

View File

@@ -1,8 +1,10 @@
import { Card, CardContent, Typography, Box, Chip, Skeleton } from '@mui/material';
import { Box, Chip } from '@mui/material';
import { BugReport } from '@mui/icons-material';
import { useQuery } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { issuesApi } from '../../services/issues';
import { WidgetCard } from '../templates/WidgetCard';
import { ChipListSkeleton } from '../templates/SkeletonPresets';
function IssueOverviewWidget() {
const navigate = useNavigate();
@@ -14,65 +16,31 @@ function IssueOverviewWidget() {
retry: 1,
});
if (isLoading) {
return (
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>Issues</Typography>
<Skeleton variant="rectangular" height={40} />
</CardContent>
</Card>
);
}
if (isError) {
return (
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>Issues</Typography>
<Typography variant="body2" color="text.secondary">
Issues konnten nicht geladen werden.
</Typography>
</CardContent>
</Card>
);
}
const visibleCounts = data.filter((s) => s.count > 0);
if (visibleCounts.length === 0) {
return (
<Card sx={{ cursor: 'pointer' }} onClick={() => navigate('/issues')}>
<CardContent>
<Typography variant="h6" gutterBottom>Issues</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: 'text.secondary' }}>
<BugReport fontSize="small" />
<Typography variant="body2">Keine offenen Issues</Typography>
</Box>
</CardContent>
</Card>
);
}
return (
<Card sx={{ cursor: 'pointer' }} onClick={() => navigate('/issues')}>
<CardContent>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1.5 }}>
<Typography variant="h6">Issues</Typography>
<BugReport fontSize="small" color="action" />
</Box>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{visibleCounts.map((s) => (
<Chip
key={s.schluessel}
label={`${s.count} ${s.bezeichnung}`}
color={s.farbe as any}
size="small"
/>
))}
</Box>
</CardContent>
</Card>
<WidgetCard
title="Issues"
icon={<BugReport fontSize="small" color="action" />}
isLoading={isLoading}
skeleton={<ChipListSkeleton />}
isError={isError}
errorMessage="Issues konnten nicht geladen werden."
isEmpty={!isLoading && !isError && visibleCounts.length === 0}
emptyMessage="Keine offenen Issues"
onClick={() => navigate('/issues')}
>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{visibleCounts.map((s) => (
<Chip
key={s.schluessel}
label={`${s.count} ${s.bezeichnung}`}
color={s.farbe as any}
size="small"
/>
))}
</Box>
</WidgetCard>
);
}

View File

@@ -1,22 +1,19 @@
import React, { useState } from 'react';
import {
Card,
CardContent,
Typography,
Box,
TextField,
Button,
MenuItem,
Select,
FormControl,
InputLabel,
Skeleton,
SelectChangeEvent,
} from '@mui/material';
import { BugReport } from '@mui/icons-material';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { issuesApi } from '../../services/issues';
import { useNotification } from '../../contexts/NotificationContext';
import { FormCard } from '../templates/FormCard';
import { WidgetCard } from '../templates/WidgetCard';
import { FormSkeleton } from '../templates/SkeletonPresets';
const PRIO_OPTIONS = [
{ value: 'niedrig', label: 'Niedrig' },
@@ -65,83 +62,67 @@ const IssueQuickAddWidget: React.FC = () => {
mutation.mutate();
};
if (typesLoading) {
return (
<WidgetCard
title="Issue melden"
icon={<BugReport color="primary" />}
isLoading
skeleton={<FormSkeleton />}
/>
);
}
return (
<Card
sx={{
height: '100%',
transition: 'all 0.3s ease',
'&:hover': { boxShadow: 3 },
}}
<FormCard
title="Issue melden"
icon={<BugReport color="primary" />}
isSubmitting={mutation.isPending}
onSubmit={handleSubmit}
submitLabel="Melden"
>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<BugReport color="primary" />
<Typography variant="h6">Issue melden</Typography>
</Box>
<TextField
fullWidth
size="small"
label="Titel"
value={titel}
onChange={(e) => setTitel(e.target.value)}
inputProps={{ maxLength: 255 }}
autoComplete="off"
/>
{typesLoading ? (
<Box>
<Skeleton variant="rectangular" height={40} sx={{ mb: 1.5, borderRadius: 1 }} />
<Skeleton variant="rectangular" height={40} sx={{ mb: 1.5, borderRadius: 1 }} />
<Skeleton variant="rectangular" height={40} sx={{ borderRadius: 1 }} />
</Box>
) : (
<Box component="form" onSubmit={handleSubmit} sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<TextField
fullWidth
size="small"
label="Titel"
value={titel}
onChange={(e) => setTitel(e.target.value)}
inputProps={{ maxLength: 255 }}
autoComplete="off"
/>
<FormControl fullWidth size="small">
<InputLabel>Typ</InputLabel>
<Select
value={typId}
label="Typ"
onChange={(e: SelectChangeEvent<number | ''>) =>
setTypId(e.target.value as number | '')
}
>
{activeTypes.map((t) => (
<MenuItem key={t.id} value={t.id}>
{t.name}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl fullWidth size="small">
<InputLabel>Typ</InputLabel>
<Select
value={typId}
label="Typ"
onChange={(e: SelectChangeEvent<number | ''>) =>
setTypId(e.target.value as number | '')
}
>
{activeTypes.map((t) => (
<MenuItem key={t.id} value={t.id}>
{t.name}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl fullWidth size="small">
<InputLabel>Priorität</InputLabel>
<Select
value={prioritaet}
label="Priorität"
onChange={(e: SelectChangeEvent<string>) => setPrioritaet(e.target.value)}
>
{PRIO_OPTIONS.map((p) => (
<MenuItem key={p.value} value={p.value}>
{p.label}
</MenuItem>
))}
</Select>
</FormControl>
<Button
type="submit"
variant="contained"
size="small"
disabled={!titel.trim() || mutation.isPending}
fullWidth
>
{mutation.isPending ? 'Wird erstellt…' : 'Melden'}
</Button>
</Box>
)}
</CardContent>
</Card>
<FormControl fullWidth size="small">
<InputLabel>Priorität</InputLabel>
<Select
value={prioritaet}
label="Priorität"
onChange={(e: SelectChangeEvent<string>) => setPrioritaet(e.target.value)}
>
{PRIO_OPTIONS.map((p) => (
<MenuItem key={p.value} value={p.value}>
{p.label}
</MenuItem>
))}
</Select>
</FormControl>
</FormCard>
);
};

View File

@@ -1,14 +1,9 @@
import {
Card,
CardContent,
Typography,
Link,
Stack,
Box,
Divider,
} from '@mui/material';
import { Link as LinkIcon, OpenInNew } from '@mui/icons-material';
import type { LinkCollection } from '../../types/config.types';
import { ListCard } from '../templates/ListCard';
interface LinksWidgetProps {
collection: LinkCollection;
@@ -16,35 +11,27 @@ interface LinksWidgetProps {
function LinksWidget({ collection }: LinksWidgetProps) {
return (
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
<LinkIcon color="primary" sx={{ mr: 1 }} />
<Typography variant="h6">{collection.name}</Typography>
</Box>
<Divider sx={{ mb: 1.5 }} />
<Stack spacing={0.5}>
{collection.links.map((link, i) => (
<Link
key={i}
href={link.url}
target="_blank"
rel="noopener noreferrer"
underline="hover"
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
py: 0.5,
}}
>
{link.name}
<OpenInNew sx={{ fontSize: 14, opacity: 0.6 }} />
</Link>
))}
</Stack>
</CardContent>
</Card>
<ListCard
title={collection.name}
icon={<LinkIcon color="primary" />}
items={collection.links}
renderItem={(link) => (
<Link
href={link.url}
target="_blank"
rel="noopener noreferrer"
underline="hover"
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
}}
>
{link.name}
<OpenInNew sx={{ fontSize: 14, opacity: 0.6 }} />
</Link>
)}
/>
);
}

View File

@@ -3,8 +3,6 @@ import {
Alert,
AlertTitle,
Box,
CircularProgress,
Divider,
Link,
Typography,
} from '@mui/material';
@@ -14,6 +12,7 @@ import { useQuery } from '@tanstack/react-query';
import { atemschutzApi } from '../../services/atemschutz';
import type { User } from '../../types/auth.types';
import type { AtemschutzUebersicht } from '../../types/atemschutz.types';
import { WidgetCard } from '../templates/WidgetCard';
// ── Constants ─────────────────────────────────────────────────────────────────
@@ -84,12 +83,11 @@ const PersonalWarningsBanner: React.FC<PersonalWarningsBannerProps> = ({ user: _
if (isLoading) {
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 1 }}>
<CircularProgress size={16} />
<Typography variant="body2" color="text.secondary">
Persönliche Fristen werden geprüft
</Typography>
</Box>
<WidgetCard
title="Persönliche Warnungen"
icon={<NotificationsActiveIcon fontSize="small" />}
isLoading
/>
);
}
@@ -115,44 +113,20 @@ const PersonalWarningsBanner: React.FC<PersonalWarningsBannerProps> = ({ user: _
const upcoming = warnings.filter((w) => w.tageRest >= 0);
return (
<Box
sx={{
border: '1px solid',
borderColor: overdue.length > 0 ? 'error.main' : 'warning.main',
borderRadius: 1,
overflow: 'hidden',
}}
>
{/* Banner header */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
px: 2,
py: 1,
bgcolor: overdue.length > 0 ? 'error.light' : 'warning.light',
}}
>
<WidgetCard
title={`Persönliche Warnungen (${warnings.length})`}
icon={
<NotificationsActiveIcon
fontSize="small"
sx={{ color: overdue.length > 0 ? 'error.dark' : 'warning.dark' }}
/>
<Typography
variant="subtitle2"
sx={{
fontWeight: 700,
color: overdue.length > 0 ? 'error.dark' : 'warning.dark',
}}
>
Persönliche Warnungen ({warnings.length})
</Typography>
</Box>
<Divider />
{/* Warning alerts */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5, p: 1.5 }}>
}
sx={{
border: '1px solid',
borderColor: overdue.length > 0 ? 'error.main' : 'warning.main',
}}
>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
{overdue.length > 0 && (
<Alert severity="error" variant="outlined">
<AlertTitle sx={{ fontWeight: 600 }}>Überfällig Handlungsbedarf</AlertTitle>
@@ -209,7 +183,7 @@ const PersonalWarningsBanner: React.FC<PersonalWarningsBannerProps> = ({ user: _
</Alert>
)}
</Box>
</Box>
</WidgetCard>
);
};

View File

@@ -1,13 +1,11 @@
import React from 'react';
import {
Card,
CardActionArea,
CardContent,
Typography,
Box,
Chip,
} from '@mui/material';
import { SvgIconComponent } from '@mui/icons-material';
import { WidgetCard } from '../templates/WidgetCard';
interface ServiceCardProps {
title: string;
@@ -27,79 +25,49 @@ const ServiceCard: React.FC<ServiceCardProps> = ({
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
<WidgetCard
title={title}
icon={
<Box
sx={{
display: 'flex',
alignItems: 'center',
bgcolor: isConnected ? 'success.light' : 'grey.300',
borderRadius: '50%',
p: 1,
}}
>
<Icon
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
mb: 2,
fontSize: 24,
color: isConnected ? 'success.dark' : 'grey.600',
}}
>
<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>
</Box>
}
action={
<Box
sx={{
width: 12,
height: 12,
borderRadius: '50%',
bgcolor: isConnected ? 'success.main' : 'grey.400',
}}
/>
}
onClick={onClick}
>
<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'}
/>
</WidgetCard>
);
};

View File

@@ -1,74 +1,3 @@
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;
// Re-export StatCard from templates for backward compatibility
export { StatCard as default } from '../templates/StatCard';
export type { StatCardProps } from '../templates/StatCard';

View File

@@ -1,14 +1,9 @@
import React from 'react';
import {
Box,
Card,
CardContent,
CircularProgress,
Chip,
Divider,
Link,
List,
ListItem,
Skeleton,
Typography,
} from '@mui/material';
import { CalendarMonth as CalendarMonthIcon } from '@mui/icons-material';
@@ -18,6 +13,8 @@ import { trainingApi } from '../../services/training';
import { eventsApi } from '../../services/events';
import type { UebungListItem, UebungTyp } from '../../types/training.types';
import type { VeranstaltungListItem } from '../../types/events.types';
import { ListCard } from '../templates/ListCard';
import { WidgetCard } from '../templates/WidgetCard';
// ---------------------------------------------------------------------------
// Color map — matches TYP_DOT_COLOR in Kalender.tsx
@@ -136,153 +133,110 @@ const UpcomingEventsWidget: React.FC = () => {
.slice(0, DISPLAY_LIMIT);
}, [trainingItems, eventItems, loading]);
// ── Loading state ─────────────────────────────────────────────────────────
if (loading) {
return (
<Card sx={{ height: '100%' }}>
<CardContent sx={{ display: 'flex', alignItems: 'center', gap: 1.5, py: 2 }}>
<CircularProgress size={18} />
<Typography variant="body2" color="text.secondary">
Termine werden geladen...
</Typography>
</CardContent>
</Card>
);
}
// ── Error state ───────────────────────────────────────────────────────────
// ── Render ────────────────────────────────────────────────────────────────
if (error) {
return (
<Card sx={{ height: '100%' }}>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
<CalendarMonthIcon fontSize="small" color="action" />
<Typography variant="h6">Nächste Termine</Typography>
</Box>
<Typography variant="body2" color="error">
Termine konnten nicht geladen werden.
</Typography>
</CardContent>
</Card>
<WidgetCard
title="Nächste Termine"
icon={<CalendarMonthIcon fontSize="small" sx={{ color: 'primary.main' }} />}
isError
errorMessage="Termine konnten nicht geladen werden."
/>
);
}
// ── Main render ───────────────────────────────────────────────────────────
return (
<Card
sx={{
height: '100%',
transition: 'box-shadow 0.3s ease',
'&:hover': { boxShadow: 3 },
}}
>
<CardContent sx={{ pb: '8px !important' }}>
{/* Header */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<CalendarMonthIcon fontSize="small" sx={{ color: 'primary.main' }} />
<Typography variant="h6" component="div">
Nächste Termine
</Typography>
</Box>
<ListCard
title="Nächste Termine"
icon={<CalendarMonthIcon fontSize="small" sx={{ color: 'primary.main' }} />}
items={entries}
renderItem={(entry) => (
<Box
sx={{
display: 'flex',
alignItems: 'flex-start',
gap: 1.5,
}}
>
{/* Colored type indicator dot */}
<Box
sx={{
mt: '4px',
flexShrink: 0,
width: 10,
height: 10,
borderRadius: '50%',
bgcolor: entry.color,
}}
/>
<Divider sx={{ mb: 1 }} />
{/* Date + title block */}
<Box sx={{ flex: 1, minWidth: 0 }}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.75,
flexWrap: 'wrap',
mb: 0.25,
}}
>
<Typography
variant="caption"
sx={{
color: 'text.secondary',
fontVariantNumeric: 'tabular-nums',
whiteSpace: 'nowrap',
}}
>
{formatDateShort(entry.date)}
{!entry.allDay && (
<> &middot; {formatTime(entry.date.toISOString())}</>
)}
</Typography>
{/* Empty state */}
{entries.length === 0 ? (
<Box sx={{ textAlign: 'center', py: 3 }}>
<Typography variant="body2" color="text.secondary">
Keine bevorstehenden Termine
<Chip
label={entry.typeLabel}
size="small"
sx={{
height: 16,
fontSize: '0.65rem',
bgcolor: `${entry.color}22`,
color: entry.color,
border: `1px solid ${entry.color}55`,
fontWeight: 600,
'& .MuiChip-label': { px: '5px' },
}}
/>
</Box>
<Typography
variant="body2"
sx={{
fontWeight: 500,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{entry.title}
</Typography>
</Box>
) : (
<List disablePadding>
{entries.map((entry, index) => (
<React.Fragment key={entry.id}>
<ListItem
disableGutters
sx={{
py: 1,
display: 'flex',
alignItems: 'flex-start',
gap: 1.5,
}}
>
{/* Colored type indicator dot */}
<Box
sx={{
mt: '4px',
flexShrink: 0,
width: 10,
height: 10,
borderRadius: '50%',
bgcolor: entry.color,
}}
/>
{/* Date + title block */}
<Box sx={{ flex: 1, minWidth: 0 }}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.75,
flexWrap: 'wrap',
mb: 0.25,
}}
>
<Typography
variant="caption"
sx={{
color: 'text.secondary',
fontVariantNumeric: 'tabular-nums',
whiteSpace: 'nowrap',
}}
>
{formatDateShort(entry.date)}
{!entry.allDay && (
<> &middot; {formatTime(entry.date.toISOString())}</>
)}
</Typography>
<Chip
label={entry.typeLabel}
size="small"
sx={{
height: 16,
fontSize: '0.65rem',
bgcolor: `${entry.color}22`,
color: entry.color,
border: `1px solid ${entry.color}55`,
fontWeight: 600,
'& .MuiChip-label': { px: '5px' },
}}
/>
</Box>
<Typography
variant="body2"
sx={{
fontWeight: 500,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{entry.title}
</Typography>
</Box>
</ListItem>
{index < entries.length - 1 && (
<Divider component="li" sx={{ listStyle: 'none' }} />
)}
</React.Fragment>
))}
</List>
)}
{/* Footer link */}
<Divider sx={{ mt: 1, mb: 1 }} />
</Box>
)}
isLoading={loading}
skeletonCount={5}
skeletonItem={
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'flex-start' }}>
<Skeleton variant="circular" width={10} height={10} sx={{ mt: '4px' }} animation="wave" />
<Box sx={{ flex: 1 }}>
<Skeleton variant="text" width="70%" animation="wave" />
<Skeleton variant="text" width="40%" animation="wave" />
</Box>
</Box>
}
emptyMessage="Keine bevorstehenden Termine"
footer={
<Box sx={{ textAlign: 'right' }}>
<Link
component={RouterLink}
@@ -294,8 +248,8 @@ const UpcomingEventsWidget: React.FC = () => {
Alle Termine
</Link>
</Box>
</CardContent>
</Card>
}
/>
);
};

View File

@@ -1,14 +1,9 @@
import React from 'react';
import {
Box,
Card,
CardContent,
CircularProgress,
Chip,
Divider,
Link,
List,
ListItem,
Skeleton,
Typography,
} from '@mui/material';
import { DirectionsCar as DirectionsCarIcon } from '@mui/icons-material';
@@ -17,6 +12,8 @@ import { useQuery } from '@tanstack/react-query';
import { bookingApi } from '../../services/bookings';
import type { FahrzeugBuchungListItem } from '../../types/booking.types';
import { BUCHUNGS_ART_COLORS, BUCHUNGS_ART_LABELS } from '../../types/booking.types';
import { ListCard } from '../templates/ListCard';
import { WidgetCard } from '../templates/WidgetCard';
// ---------------------------------------------------------------------------
// Helpers
@@ -55,169 +52,125 @@ const VehicleBookingListWidget: React.FC = () => {
[rawItems],
);
// ── Loading state ─────────────────────────────────────────────────────────
if (isLoading) {
return (
<Card sx={{ height: '100%' }}>
<CardContent sx={{ display: 'flex', alignItems: 'center', gap: 1.5, py: 2 }}>
<CircularProgress size={18} />
<Typography variant="body2" color="text.secondary">
Buchungen werden geladen...
</Typography>
</CardContent>
</Card>
);
}
// ── Error state ───────────────────────────────────────────────────────────
if (isError) {
return (
<Card sx={{ height: '100%' }}>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
<DirectionsCarIcon fontSize="small" color="action" />
<Typography variant="h6">Nächste Fahrzeugbuchungen</Typography>
</Box>
<Typography variant="body2" color="error">
Buchungen konnten nicht geladen werden.
</Typography>
</CardContent>
</Card>
<WidgetCard
title="Nächste Fahrzeugbuchungen"
icon={<DirectionsCarIcon fontSize="small" sx={{ color: 'primary.main' }} />}
isError
errorMessage="Buchungen konnten nicht geladen werden."
/>
);
}
// ── Main render ───────────────────────────────────────────────────────────
return (
<Card
sx={{
height: '100%',
transition: 'box-shadow 0.3s ease',
'&:hover': { boxShadow: 3 },
}}
>
<CardContent sx={{ pb: '8px !important' }}>
{/* Header */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<DirectionsCarIcon fontSize="small" sx={{ color: 'primary.main' }} />
<Typography variant="h6" component="div">
Nächste Fahrzeugbuchungen
</Typography>
</Box>
<ListCard
title="Nächste Fahrzeugbuchungen"
icon={<DirectionsCarIcon fontSize="small" sx={{ color: 'primary.main' }} />}
items={items}
renderItem={(booking) => {
const color = BUCHUNGS_ART_COLORS[booking.buchungs_art] ?? '#9e9e9e';
const label = BUCHUNGS_ART_LABELS[booking.buchungs_art] ?? booking.buchungs_art;
return (
<Box
sx={{
display: 'flex',
alignItems: 'flex-start',
gap: 1.5,
}}
>
{/* Colored type indicator dot */}
<Box
sx={{
mt: '4px',
flexShrink: 0,
width: 10,
height: 10,
borderRadius: '50%',
bgcolor: color,
}}
/>
<Divider sx={{ mb: 1 }} />
{/* Date + title block */}
<Box sx={{ flex: 1, minWidth: 0 }}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.75,
flexWrap: 'wrap',
mb: 0.25,
}}
>
<Typography
variant="caption"
sx={{
color: 'text.secondary',
fontVariantNumeric: 'tabular-nums',
whiteSpace: 'nowrap',
}}
>
{formatDateShort(booking.beginn)}
{' '}&middot;{' '}
{formatTime(booking.beginn)}
</Typography>
{/* Empty state */}
{items.length === 0 ? (
<Box sx={{ textAlign: 'center', py: 3 }}>
<Typography variant="body2" color="text.secondary">
Keine bevorstehenden Buchungen
</Typography>
<Chip
label={label}
size="small"
sx={{
height: 16,
fontSize: '0.65rem',
bgcolor: `${color}22`,
color: color,
border: `1px solid ${color}55`,
fontWeight: 600,
'& .MuiChip-label': { px: '5px' },
}}
/>
</Box>
<Typography
variant="body2"
sx={{
fontWeight: 500,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{booking.titel}
</Typography>
<Typography
variant="caption"
color="text.secondary"
sx={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
display: 'block',
}}
>
{booking.fahrzeug_name}
</Typography>
</Box>
</Box>
) : (
<List disablePadding>
{items.map((booking, index) => {
const color = BUCHUNGS_ART_COLORS[booking.buchungs_art] ?? '#9e9e9e';
const label = BUCHUNGS_ART_LABELS[booking.buchungs_art] ?? booking.buchungs_art;
return (
<React.Fragment key={booking.id}>
<ListItem
disableGutters
sx={{
py: 1,
display: 'flex',
alignItems: 'flex-start',
gap: 1.5,
}}
>
{/* Colored type indicator dot */}
<Box
sx={{
mt: '4px',
flexShrink: 0,
width: 10,
height: 10,
borderRadius: '50%',
bgcolor: color,
}}
/>
{/* Date + title block */}
<Box sx={{ flex: 1, minWidth: 0 }}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.75,
flexWrap: 'wrap',
mb: 0.25,
}}
>
<Typography
variant="caption"
sx={{
color: 'text.secondary',
fontVariantNumeric: 'tabular-nums',
whiteSpace: 'nowrap',
}}
>
{formatDateShort(booking.beginn)}
{' '}&middot;{' '}
{formatTime(booking.beginn)}
</Typography>
<Chip
label={label}
size="small"
sx={{
height: 16,
fontSize: '0.65rem',
bgcolor: `${color}22`,
color: color,
border: `1px solid ${color}55`,
fontWeight: 600,
'& .MuiChip-label': { px: '5px' },
}}
/>
</Box>
<Typography
variant="body2"
sx={{
fontWeight: 500,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{booking.titel}
</Typography>
<Typography
variant="caption"
color="text.secondary"
sx={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
display: 'block',
}}
>
{booking.fahrzeug_name}
</Typography>
</Box>
</ListItem>
{index < items.length - 1 && (
<Divider component="li" sx={{ listStyle: 'none' }} />
)}
</React.Fragment>
);
})}
</List>
)}
{/* Footer link */}
<Divider sx={{ mt: 1, mb: 1 }} />
);
}}
isLoading={isLoading}
skeletonCount={5}
skeletonItem={
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'flex-start' }}>
<Skeleton variant="circular" width={10} height={10} sx={{ mt: '4px' }} animation="wave" />
<Box sx={{ flex: 1 }}>
<Skeleton variant="text" width="70%" animation="wave" />
<Skeleton variant="text" width="40%" animation="wave" />
</Box>
</Box>
}
emptyMessage="Keine bevorstehenden Buchungen"
footer={
<Box sx={{ textAlign: 'right' }}>
<Link
component={RouterLink}
@@ -229,8 +182,8 @@ const VehicleBookingListWidget: React.FC = () => {
Alle Buchungen
</Link>
</Box>
</CardContent>
</Card>
}
/>
);
};

View File

@@ -1,16 +1,10 @@
import React, { useState } from 'react';
import {
Card,
CardContent,
Typography,
Box,
TextField,
Button,
MenuItem,
Select,
FormControl,
InputLabel,
Skeleton,
SelectChangeEvent,
} from '@mui/material';
import { DirectionsCar } from '@mui/icons-material';
@@ -19,6 +13,9 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { bookingApi, fetchVehicles } from '../../services/bookings';
import type { CreateBuchungInput } from '../../types/booking.types';
import { useNotification } from '../../contexts/NotificationContext';
import { FormCard } from '../templates/FormCard';
import { WidgetCard } from '../templates/WidgetCard';
import { FormSkeleton } from '../templates/SkeletonPresets';
function toDatetimeLocal(date: Date): string {
const pad = (n: number) => String(n).padStart(2, '0');
@@ -94,98 +91,81 @@ const VehicleBookingQuickAddWidget: React.FC = () => {
mutation.mutate();
};
if (vehiclesLoading) {
return (
<WidgetCard
title="Fahrzeugbuchung"
icon={<DirectionsCar color="primary" />}
isLoading
skeleton={<FormSkeleton />}
/>
);
}
return (
<Card
sx={{
height: '100%',
transition: 'all 0.3s ease',
'&:hover': { boxShadow: 3 },
}}
<FormCard
title="Fahrzeugbuchung"
icon={<DirectionsCar color="primary" />}
isSubmitting={mutation.isPending}
onSubmit={handleSubmit}
submitLabel="Erstellen"
>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<DirectionsCar color="primary" />
<Typography variant="h6">Fahrzeugbuchung</Typography>
</Box>
<FormControl fullWidth size="small">
<InputLabel>Fahrzeug</InputLabel>
<Select
value={fahrzeugId}
label="Fahrzeug"
onChange={(e: SelectChangeEvent<string>) => setFahrzeugId(e.target.value)}
>
{(vehicles ?? []).map((v) => (
<MenuItem key={v.id} value={v.id}>
{v.bezeichnung}
</MenuItem>
))}
</Select>
</FormControl>
{vehiclesLoading ? (
<Box>
<Skeleton variant="rectangular" height={40} sx={{ mb: 1.5, borderRadius: 1 }} />
<Skeleton variant="rectangular" height={40} sx={{ mb: 1.5, borderRadius: 1 }} />
<Skeleton variant="rectangular" height={40} sx={{ mb: 1.5, borderRadius: 1 }} />
<Skeleton variant="rectangular" height={40} sx={{ borderRadius: 1 }} />
</Box>
) : (
<Box component="form" onSubmit={handleSubmit} sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<FormControl fullWidth size="small">
<InputLabel>Fahrzeug</InputLabel>
<Select
value={fahrzeugId}
label="Fahrzeug"
onChange={(e: SelectChangeEvent<string>) => setFahrzeugId(e.target.value)}
>
{(vehicles ?? []).map((v) => (
<MenuItem key={v.id} value={v.id}>
{v.bezeichnung}
</MenuItem>
))}
</Select>
</FormControl>
<TextField
fullWidth
size="small"
label="Titel"
value={titel}
onChange={(e) => setTitel(e.target.value)}
required
inputProps={{ maxLength: 250 }}
/>
<TextField
fullWidth
size="small"
label="Titel"
value={titel}
onChange={(e) => setTitel(e.target.value)}
required
inputProps={{ maxLength: 250 }}
/>
<GermanDateField
mode="datetime"
fullWidth
size="small"
label="Beginn"
value={beginn}
onChange={(v) => setBeginn(v)}
required
/>
<GermanDateField
mode="datetime"
fullWidth
size="small"
label="Beginn"
value={beginn}
onChange={(v) => setBeginn(v)}
required
/>
<GermanDateField
mode="datetime"
fullWidth
size="small"
label="Ende"
value={ende}
onChange={(v) => setEnde(v)}
required
/>
<GermanDateField
mode="datetime"
fullWidth
size="small"
label="Ende"
value={ende}
onChange={(v) => setEnde(v)}
required
/>
<TextField
fullWidth
size="small"
label="Beschreibung (optional)"
value={beschreibung}
onChange={(e) => setBeschreibung(e.target.value)}
multiline
rows={2}
inputProps={{ maxLength: 1000 }}
/>
<Button
type="submit"
variant="contained"
size="small"
disabled={!fahrzeugId || !titel.trim() || !beginn || !ende || mutation.isPending}
fullWidth
>
{mutation.isPending ? 'Wird erstellt…' : 'Erstellen'}
</Button>
</Box>
)}
</CardContent>
</Card>
<TextField
fullWidth
size="small"
label="Beschreibung (optional)"
value={beschreibung}
onChange={(e) => setBeschreibung(e.target.value)}
multiline
rows={2}
inputProps={{ maxLength: 1000 }}
/>
</FormCard>
);
};

View File

@@ -1,10 +1,7 @@
import React from 'react';
import {
Card,
CardContent,
Typography,
Box,
Divider,
Skeleton,
Chip,
} from '@mui/material';
@@ -16,6 +13,8 @@ import { vikunjaApi } from '../../services/vikunja';
import type { VikunjaTask } from '../../types/vikunja.types';
import { safeOpenUrl } from '../../utils/safeOpenUrl';
import { useCountUp } from '../../hooks/useCountUp';
import { ListCard } from '../templates/ListCard';
import { WidgetCard } from '../templates/WidgetCard';
const PRIORITY_LABELS: Record<number, { label: string; color: 'default' | 'warning' | 'error' }> = {
0: { label: 'Keine', color: 'default' },
@@ -26,9 +25,8 @@ const PRIORITY_LABELS: Record<number, { label: string; color: 'default' | 'warni
5: { label: 'Dringend', color: 'error' },
};
const TaskRow: React.FC<{ task: VikunjaTask; showDivider: boolean; vikunjaUrl: string }> = ({
const TaskRow: React.FC<{ task: VikunjaTask; vikunjaUrl: string }> = ({
task,
showDivider,
vikunjaUrl,
}) => {
const handleClick = () => {
@@ -42,47 +40,43 @@ const TaskRow: React.FC<{ task: VikunjaTask; showDivider: boolean; vikunjaUrl: s
const priority = PRIORITY_LABELS[task.priority] ?? PRIORITY_LABELS[0];
return (
<>
<Box
onClick={handleClick}
sx={{
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
py: 1.5,
px: 1,
cursor: 'pointer',
borderRadius: 1,
transition: 'background-color 0.15s ease',
'&:hover': { bgcolor: 'action.hover' },
}}
>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="subtitle2" noWrap>
{task.title}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.5, flexWrap: 'wrap' }}>
{dueDateStr && (
<Typography
variant="caption"
color={overdue ? 'error' : 'text.secondary'}
>
{dueDateStr}
</Typography>
)}
{task.priority > 0 && (
<Chip
label={priority.label}
size="small"
color={priority.color}
sx={{ height: 18, fontSize: '0.65rem' }}
/>
)}
</Box>
<Box
onClick={handleClick}
sx={{
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
px: 1,
cursor: 'pointer',
borderRadius: 1,
transition: 'background-color 0.15s ease',
'&:hover': { bgcolor: 'action.hover' },
}}
>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="subtitle2" noWrap>
{task.title}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.5, flexWrap: 'wrap' }}>
{dueDateStr && (
<Typography
variant="caption"
color={overdue ? 'error' : 'text.secondary'}
>
{dueDateStr}
</Typography>
)}
{task.priority > 0 && (
<Chip
label={priority.label}
size="small"
color={priority.color}
sx={{ height: 18, fontSize: '0.65rem' }}
/>
)}
</Box>
</Box>
{showDivider && <Divider />}
</>
</Box>
);
};
@@ -100,78 +94,49 @@ const VikunjaMyTasksWidget: React.FC = () => {
if (!configured) {
return (
<Card sx={{ height: '100%' }}>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<AssignmentInd color="disabled" />
<Typography variant="h6" color="text.secondary">
Meine Aufgaben
</Typography>
</Box>
<Typography variant="body2" color="text.secondary" sx={{ py: 2, textAlign: 'center' }}>
Vikunja nicht eingerichtet
</Typography>
</CardContent>
</Card>
<WidgetCard
title="Meine Aufgaben"
icon={<AssignmentInd color="disabled" />}
isEmpty
emptyMessage="Vikunja nicht eingerichtet"
/>
);
}
if (isError) {
return (
<WidgetCard
title="Meine Aufgaben"
icon={<AssignmentInd color="primary" />}
isError
errorMessage="Vikunja nicht erreichbar"
/>
);
}
const titleAction = !isLoading && !isError && tasks.length > 0 ? (
<Chip label={animatedTaskCount} size="small" color="primary" />
) : undefined;
return (
<Card
sx={{
height: '100%',
transition: 'all 0.3s ease',
'&:hover': { boxShadow: 3 },
}}
>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<AssignmentInd color="primary" />
<Typography variant="h6" sx={{ flexGrow: 1 }}>
Meine Aufgaben
</Typography>
{!isLoading && !isError && tasks.length > 0 && (
<Chip label={animatedTaskCount} size="small" color="primary" />
)}
<ListCard
title="Meine Aufgaben"
icon={<AssignmentInd color="primary" />}
action={titleAction}
items={tasks}
renderItem={(task) => (
<TaskRow key={task.id} task={task} vikunjaUrl={data?.vikunjaUrl ?? ''} />
)}
isLoading={isLoading}
skeletonCount={3}
skeletonItem={
<Box sx={{ mb: 0.5 }}>
<Skeleton variant="text" width="70%" height={22} animation="wave" />
<Skeleton variant="text" width="40%" height={18} animation="wave" />
</Box>
{isLoading && (
<Box>
{[1, 2, 3].map((n) => (
<Box key={n} sx={{ mb: 1.5 }}>
<Skeleton variant="text" width="70%" height={22} />
<Skeleton variant="text" width="40%" height={18} />
</Box>
))}
</Box>
)}
{isError && (
<Typography variant="body2" color="text.secondary" sx={{ py: 2, textAlign: 'center' }}>
Vikunja nicht erreichbar
</Typography>
)}
{!isLoading && !isError && tasks.length === 0 && (
<Typography variant="body2" color="text.secondary" sx={{ py: 2, textAlign: 'center' }}>
Keine offenen Aufgaben
</Typography>
)}
{!isLoading && !isError && tasks.length > 0 && (
<Box>
{tasks.map((task, index) => (
<TaskRow
key={task.id}
task={task}
showDivider={index < tasks.length - 1}
vikunjaUrl={data?.vikunjaUrl ?? ''}
/>
))}
</Box>
)}
</CardContent>
</Card>
}
emptyMessage="Keine offenen Aufgaben"
/>
);
};

View File

@@ -1,16 +1,10 @@
import React, { useState } from 'react';
import {
Card,
CardContent,
Typography,
Box,
TextField,
Button,
MenuItem,
Select,
FormControl,
InputLabel,
Skeleton,
SelectChangeEvent,
} from '@mui/material';
import { AddTask } from '@mui/icons-material';
@@ -18,6 +12,9 @@ import GermanDateField from '../shared/GermanDateField';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { vikunjaApi } from '../../services/vikunja';
import { useNotification } from '../../contexts/NotificationContext';
import { FormCard } from '../templates/FormCard';
import { WidgetCard } from '../templates/WidgetCard';
import { FormSkeleton } from '../templates/SkeletonPresets';
const VikunjaQuickAddWidget: React.FC = () => {
const [title, setTitle] = useState('');
@@ -63,92 +60,69 @@ const VikunjaQuickAddWidget: React.FC = () => {
if (!configured) {
return (
<Card sx={{ height: '100%' }}>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<AddTask color="disabled" />
<Typography variant="h6" color="text.secondary">
Aufgabe erstellen
</Typography>
</Box>
<Typography variant="body2" color="text.secondary" sx={{ py: 2, textAlign: 'center' }}>
Vikunja nicht eingerichtet
</Typography>
</CardContent>
</Card>
<WidgetCard
title="Aufgabe erstellen"
icon={<AddTask color="disabled" />}
isEmpty
emptyMessage="Vikunja nicht eingerichtet"
/>
);
}
if (projectsLoading) {
return (
<WidgetCard
title="Aufgabe erstellen"
icon={<AddTask color="primary" />}
isLoading
skeleton={<FormSkeleton />}
/>
);
}
return (
<Card
sx={{
height: '100%',
transition: 'all 0.3s ease',
'&:hover': { boxShadow: 3 },
}}
<FormCard
title="Aufgabe erstellen"
icon={<AddTask color="primary" />}
isSubmitting={mutation.isPending}
onSubmit={handleSubmit}
submitLabel="Erstellen"
>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<AddTask color="primary" />
<Typography variant="h6">Aufgabe erstellen</Typography>
</Box>
<FormControl fullWidth size="small">
<InputLabel>Projekt</InputLabel>
<Select
value={projectId}
label="Projekt"
onChange={(e: SelectChangeEvent<number | ''>) =>
setProjectId(e.target.value as number | '')
}
>
{projects.map((p) => (
<MenuItem key={p.id} value={p.id}>
{p.title}
</MenuItem>
))}
</Select>
</FormControl>
{projectsLoading ? (
<Box>
<Skeleton variant="rectangular" height={40} sx={{ mb: 1.5, borderRadius: 1 }} />
<Skeleton variant="rectangular" height={40} sx={{ mb: 1.5, borderRadius: 1 }} />
<Skeleton variant="rectangular" height={40} sx={{ borderRadius: 1 }} />
</Box>
) : (
<Box component="form" onSubmit={handleSubmit} sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<FormControl fullWidth size="small">
<InputLabel>Projekt</InputLabel>
<Select
value={projectId}
label="Projekt"
onChange={(e: SelectChangeEvent<number | ''>) =>
setProjectId(e.target.value as number | '')
}
>
{projects.map((p) => (
<MenuItem key={p.id} value={p.id}>
{p.title}
</MenuItem>
))}
</Select>
</FormControl>
<TextField
fullWidth
size="small"
label="Titel"
value={title}
onChange={(e) => setTitle(e.target.value)}
inputProps={{ maxLength: 250 }}
/>
<TextField
fullWidth
size="small"
label="Titel"
value={title}
onChange={(e) => setTitle(e.target.value)}
inputProps={{ maxLength: 250 }}
/>
<GermanDateField
mode="date"
fullWidth
size="small"
label="Fälligkeitsdatum (optional)"
value={dueDate}
onChange={(v) => setDueDate(v)}
/>
<Button
type="submit"
variant="contained"
size="small"
disabled={!title.trim() || projectId === '' || mutation.isPending}
fullWidth
>
{mutation.isPending ? 'Wird erstellt…' : 'Erstellen'}
</Button>
</Box>
)}
</CardContent>
</Card>
<GermanDateField
mode="date"
fullWidth
size="small"
label="Fälligkeitsdatum (optional)"
value={dueDate}
onChange={(v) => setDueDate(v)}
/>
</FormCard>
);
};

View File

@@ -1,6 +1,5 @@
import React, { useState } from 'react';
import {
Alert,
Box,
Button,
Chip,

View File

@@ -0,0 +1,59 @@
import React from 'react';
import {
Button,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
} from '@mui/material';
export interface ConfirmDialogProps {
open: boolean;
onClose: () => void;
onConfirm: () => void;
title: string;
message: string | React.ReactNode;
confirmLabel?: string;
confirmColor?: 'primary' | 'error' | 'warning';
isLoading?: boolean;
}
/** Standard confirmation dialog with cancel/confirm buttons. */
export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
open,
onClose,
onConfirm,
title,
message,
confirmLabel = 'Bestätigen',
confirmColor = 'primary',
isLoading = false,
}) => {
return (
<Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
<DialogTitle>{title}</DialogTitle>
<DialogContent>
{typeof message === 'string' ? (
<DialogContentText>{message}</DialogContentText>
) : (
message
)}
</DialogContent>
<DialogActions>
<Button onClick={onClose} disabled={isLoading}>
Abbrechen
</Button>
<Button
onClick={onConfirm}
variant="contained"
color={confirmColor}
disabled={isLoading}
>
{isLoading ? <CircularProgress size={20} /> : confirmLabel}
</Button>
</DialogActions>
</Dialog>
);
};

View File

@@ -0,0 +1,238 @@
import React, { useState, useMemo, useCallback } from 'react';
import {
Box,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TablePagination,
TableRow,
TextField,
Typography,
Skeleton,
InputAdornment,
} from '@mui/material';
import SearchIcon from '@mui/icons-material/Search';
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';
import InboxIcon from '@mui/icons-material/Inbox';
export interface Column<T> {
key: string;
label: string;
align?: 'left' | 'center' | 'right';
width?: number | string;
render?: (row: T) => React.ReactNode;
sortable?: boolean;
searchable?: boolean;
}
export interface DataTableProps<T> {
columns: Column<T>[];
data: T[];
isLoading?: boolean;
skeletonRows?: number;
emptyMessage?: string;
emptyIcon?: React.ReactNode;
onRowClick?: (row: T) => void;
rowKey: (row: T) => string | number;
searchPlaceholder?: string;
searchEnabled?: boolean;
paginationEnabled?: boolean;
defaultRowsPerPage?: number;
rowsPerPageOptions?: number[];
filters?: React.ReactNode;
actions?: React.ReactNode;
title?: string;
stickyHeader?: boolean;
maxHeight?: number | string;
size?: 'small' | 'medium';
dense?: boolean;
}
/** Universal data table with search, sorting, and pagination. */
export function DataTable<T>({
columns,
data,
isLoading = false,
skeletonRows = 5,
emptyMessage = 'Keine Einträge',
emptyIcon,
onRowClick,
rowKey,
searchPlaceholder = 'Suchen...',
searchEnabled = true,
paginationEnabled = true,
defaultRowsPerPage = 10,
rowsPerPageOptions = [5, 10, 25],
filters,
actions,
title,
stickyHeader = false,
maxHeight,
size = 'small',
dense = false,
}: DataTableProps<T>) {
const [search, setSearch] = useState('');
const [sortKey, setSortKey] = useState<string | null>(null);
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(defaultRowsPerPage);
const handleSort = useCallback((key: string) => {
if (sortKey === key) {
if (sortDir === 'asc') {
setSortDir('desc');
} else {
setSortKey(null);
setSortDir('asc');
}
} else {
setSortKey(key);
setSortDir('asc');
}
}, [sortKey, sortDir]);
const filteredData = useMemo(() => {
if (!search.trim()) return data;
const term = search.toLowerCase();
const searchableCols = columns.filter((c) => c.searchable !== false);
return data.filter((row) =>
searchableCols.some((col) => {
const val = (row as Record<string, unknown>)[col.key];
return val != null && String(val).toLowerCase().includes(term);
})
);
}, [data, search, columns]);
const sortedData = useMemo(() => {
if (!sortKey) return filteredData;
return [...filteredData].sort((a, b) => {
const aVal = (a as Record<string, unknown>)[sortKey];
const bVal = (b as Record<string, unknown>)[sortKey];
if (aVal == null && bVal == null) return 0;
if (aVal == null) return 1;
if (bVal == null) return -1;
const cmp = String(aVal).localeCompare(String(bVal), undefined, { numeric: true });
return sortDir === 'asc' ? cmp : -cmp;
});
}, [filteredData, sortKey, sortDir]);
const paginatedData = paginationEnabled
? sortedData.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage)
: sortedData;
const showToolbar = title || actions || searchEnabled;
return (
<Paper>
{showToolbar && (
<Box sx={{ p: 2, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 2 }}>
{title && <Typography variant="h6">{title}</Typography>}
<Box sx={{ flex: 1 }} />
{searchEnabled && (
<TextField
size="small"
placeholder={searchPlaceholder}
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
sx={{ maxWidth: 300 }}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon fontSize="small" />
</InputAdornment>
),
}}
/>
)}
{actions}
</Box>
)}
{filters && <Box sx={{ px: 2, pb: 1 }}>{filters}</Box>}
<TableContainer sx={{ maxHeight }}>
<Table size={dense ? 'small' : size} stickyHeader={stickyHeader}>
<TableHead>
<TableRow>
{columns.map((col) => (
<TableCell
key={col.key}
align={col.align}
width={col.width}
sx={{
cursor: col.sortable !== false ? 'pointer' : 'default',
bgcolor: 'background.default',
userSelect: 'none',
}}
onClick={() => col.sortable !== false && handleSort(col.key)}
>
<Box display="inline-flex" alignItems="center" gap={0.5}>
{col.label}
{sortKey === col.key && (
sortDir === 'asc'
? <ArrowUpwardIcon sx={{ fontSize: 14 }} />
: <ArrowDownwardIcon sx={{ fontSize: 14 }} />
)}
</Box>
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{isLoading ? (
Array.from({ length: skeletonRows }, (_, i) => (
<TableRow key={i}>
{columns.map((col) => (
<TableCell key={col.key}>
<Skeleton animation="wave" />
</TableCell>
))}
</TableRow>
))
) : paginatedData.length === 0 ? (
<TableRow>
<TableCell colSpan={columns.length} align="center" sx={{ py: 6 }}>
<Box display="flex" flexDirection="column" alignItems="center" color="text.secondary">
{emptyIcon ?? <InboxIcon sx={{ fontSize: 40, mb: 1, opacity: 0.5 }} />}
<Typography variant="body2" color="text.secondary">
{emptyMessage}
</Typography>
</Box>
</TableCell>
</TableRow>
) : (
paginatedData.map((row) => (
<TableRow
key={rowKey(row)}
hover
onClick={() => onRowClick?.(row)}
sx={{ cursor: onRowClick ? 'pointer' : 'default' }}
>
{columns.map((col) => (
<TableCell key={col.key} align={col.align}>
{col.render ? col.render(row) : (row as Record<string, unknown>)[col.key] as React.ReactNode}
</TableCell>
))}
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
{paginationEnabled && !isLoading && (
<TablePagination
component="div"
count={filteredData.length}
page={page}
onPageChange={(_, p) => setPage(p)}
rowsPerPage={rowsPerPage}
onRowsPerPageChange={(e) => { setRowsPerPage(parseInt(e.target.value, 10)); setPage(0); }}
rowsPerPageOptions={rowsPerPageOptions}
labelRowsPerPage="Zeilen pro Seite:"
/>
)}
</Paper>
);
}

View File

@@ -0,0 +1,74 @@
import React, { useState, useEffect } from 'react';
import { Box, Tab, Tabs } from '@mui/material';
import { useSearchParams } from 'react-router-dom';
import { PageHeader } from './PageHeader';
import { TabPanel } from './TabPanel';
import type { BreadcrumbItem } from './PageHeader';
export interface TabDef {
label: React.ReactNode;
content: React.ReactNode;
icon?: React.ReactElement;
disabled?: boolean;
}
export interface DetailLayoutProps {
title: string;
breadcrumbs?: BreadcrumbItem[];
actions?: React.ReactNode;
tabs: TabDef[];
backTo?: string;
isLoading?: boolean;
skeleton?: React.ReactNode;
}
/** Detail page layout with PageHeader and tab navigation synced to URL. */
export const DetailLayout: React.FC<DetailLayoutProps> = ({
title,
breadcrumbs,
actions,
tabs,
backTo,
isLoading = false,
skeleton,
}) => {
const [searchParams, setSearchParams] = useSearchParams();
const [tab, setTab] = useState(() => {
const t = parseInt(searchParams.get('tab') ?? '0', 10);
return isNaN(t) || t < 0 || t >= tabs.length ? 0 : t;
});
useEffect(() => {
const newParams = new URLSearchParams(searchParams);
if (tab === 0) {
newParams.delete('tab');
} else {
newParams.set('tab', String(tab));
}
setSearchParams(newParams, { replace: true });
}, [tab]);
return (
<Box>
<PageHeader title={title} breadcrumbs={breadcrumbs} actions={actions} backTo={backTo} />
{isLoading ? (
skeleton
) : (
<>
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
<Tabs value={tab} onChange={(_, v) => setTab(v)} variant="scrollable" scrollButtons="auto">
{tabs.map((t, i) => (
<Tab key={i} label={t.label} icon={t.icon} disabled={t.disabled} />
))}
</Tabs>
</Box>
{tabs.map((t, i) => (
<TabPanel key={i} value={tab} index={i}>
{t.content}
</TabPanel>
))}
</>
)}
</Box>
);
};

View File

@@ -0,0 +1,90 @@
import React from 'react';
import {
Accordion,
AccordionDetails,
AccordionSummary,
Badge,
Box,
Button,
TextField,
Typography,
InputAdornment,
} from '@mui/material';
import SearchIcon from '@mui/icons-material/Search';
import FilterListIcon from '@mui/icons-material/FilterList';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
export interface FilterBarProps {
searchValue?: string;
onSearchChange?: (value: string) => void;
searchPlaceholder?: string;
children?: React.ReactNode;
activeFilterCount?: number;
onClearFilters?: () => void;
collapsible?: boolean;
}
/** Search + filter controls bar. Supports collapsible accordion mode. */
export const FilterBar: React.FC<FilterBarProps> = ({
searchValue,
onSearchChange,
searchPlaceholder = 'Suchen...',
children,
activeFilterCount = 0,
onClearFilters,
collapsible = false,
}) => {
const searchField = onSearchChange ? (
<TextField
size="small"
placeholder={searchPlaceholder}
value={searchValue ?? ''}
onChange={(e) => onSearchChange(e.target.value)}
sx={{ minWidth: 200 }}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon fontSize="small" />
</InputAdornment>
),
}}
/>
) : null;
const clearButton = activeFilterCount > 0 && onClearFilters ? (
<Button size="small" onClick={onClearFilters}>
Filter zurücksetzen
</Button>
) : null;
if (collapsible) {
return (
<Accordion disableGutters elevation={0} sx={{ mb: 2, '&:before': { display: 'none' } }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Box display="flex" alignItems="center" gap={1}>
<Badge badgeContent={activeFilterCount} color="primary">
<FilterListIcon />
</Badge>
<Typography variant="body2">Filter</Typography>
</Box>
</AccordionSummary>
<AccordionDetails>
<Box sx={{ display: 'flex', gap: 1.5, flexWrap: 'wrap', alignItems: 'center' }}>
{searchField}
{children}
{clearButton}
</Box>
</AccordionDetails>
</Accordion>
);
}
return (
<Box sx={{ display: 'flex', gap: 1.5, mb: 2, flexWrap: 'wrap', alignItems: 'center' }}>
{searchField}
{children}
<Box sx={{ flex: 1 }} />
{clearButton}
</Box>
);
};

View File

@@ -0,0 +1,44 @@
import React from 'react';
import { Box, Button, CircularProgress } from '@mui/material';
import { WidgetCard } from './WidgetCard';
export interface FormCardProps {
title: string;
icon?: React.ReactNode;
isSubmitting?: boolean;
onSubmit?: (e: React.FormEvent<HTMLFormElement>) => void;
submitLabel?: string;
children: React.ReactNode;
}
/** Quick-action form widget built on WidgetCard. */
export const FormCard: React.FC<FormCardProps> = ({
title,
icon,
isSubmitting = false,
onSubmit,
submitLabel = 'Erstellen',
children,
}) => {
return (
<WidgetCard title={title} icon={icon}>
<Box
component="form"
onSubmit={onSubmit}
sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}
>
{children}
<Box display="flex" justifyContent="flex-end" mt={0.5}>
<Button
type="submit"
variant="contained"
disabled={isSubmitting}
startIcon={isSubmitting ? <CircularProgress size={16} color="inherit" /> : undefined}
>
{submitLabel}
</Button>
</Box>
</Box>
</WidgetCard>
);
};

View File

@@ -0,0 +1,52 @@
import React from 'react';
import {
Box,
Button,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
} from '@mui/material';
export interface FormDialogProps {
open: boolean;
onClose: () => void;
onSubmit: () => void;
title: string;
submitLabel?: string;
isSubmitting?: boolean;
maxWidth?: 'xs' | 'sm' | 'md';
children: React.ReactNode;
}
/** Dialog with form content and submit/cancel buttons. */
export const FormDialog: React.FC<FormDialogProps> = ({
open,
onClose,
onSubmit,
title,
submitLabel = 'Speichern',
isSubmitting = false,
maxWidth = 'sm',
children,
}) => {
return (
<Dialog open={open} onClose={onClose} maxWidth={maxWidth} fullWidth>
<DialogTitle>{title}</DialogTitle>
<DialogContent sx={{ pt: '16px !important' }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{children}
</Box>
</DialogContent>
<DialogActions>
<Button onClick={onClose} disabled={isSubmitting}>
Abbrechen
</Button>
<Button onClick={onSubmit} variant="contained" disabled={isSubmitting}>
{isSubmitting ? <CircularProgress size={20} /> : submitLabel}
</Button>
</DialogActions>
</Dialog>
);
};

View File

@@ -0,0 +1,39 @@
import React from 'react';
import { Box, Paper, Typography } from '@mui/material';
export interface FormLayoutProps {
children: React.ReactNode;
onSubmit?: (e: React.FormEvent<HTMLFormElement>) => void;
actions?: React.ReactNode;
title?: string;
}
/** Standard form page wrapper with Paper, optional title, and action slot. */
export const FormLayout: React.FC<FormLayoutProps> = ({
children,
onSubmit,
actions,
title,
}) => {
return (
<Paper sx={{ p: 3 }}>
{title && (
<Typography variant="h6" sx={{ mb: 2 }}>
{title}
</Typography>
)}
<Box
component={onSubmit ? 'form' : 'div'}
onSubmit={onSubmit}
sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}
>
{children}
{actions && (
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1, mt: 1 }}>
{actions}
</Box>
)}
</Box>
</Paper>
);
};

View File

@@ -0,0 +1,59 @@
import React from 'react';
import { Box, Typography } from '@mui/material';
import { InfoGridSkeleton } from './SkeletonPresets';
export interface InfoField {
label: string;
value: React.ReactNode;
fullWidth?: boolean;
}
export interface InfoGridProps {
fields: InfoField[];
columns?: 1 | 2;
isLoading?: boolean;
}
/** Key-value display grid with golden-ratio label proportions. */
export const InfoGrid: React.FC<InfoGridProps> = ({
fields,
columns = 1,
isLoading = false,
}) => {
if (isLoading) {
return <InfoGridSkeleton rows={fields.length || 4} />;
}
const wrapper = columns === 2
? { display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '0 16px' }
: {};
return (
<Box sx={wrapper}>
{fields.map((field, i) => (
<Box
key={i}
sx={{
display: 'flex',
gap: 1,
py: 0.75,
borderBottom: '1px solid',
borderColor: 'divider',
...(field.fullWidth && columns === 2 ? { gridColumn: '1 / -1' } : {}),
}}
>
<Typography
variant="body2"
color="text.secondary"
sx={{ minWidth: 180, flexShrink: 0 }}
>
{field.label}
</Typography>
<Typography variant="body2" component="div" sx={{ flex: 1 }}>
{field.value}
</Typography>
</Box>
))}
</Box>
);
};

View File

@@ -0,0 +1,77 @@
import React from 'react';
import { Box, Divider } from '@mui/material';
import { WidgetCard } from './WidgetCard';
import { ItemListSkeleton } from './SkeletonPresets';
import type { SxProps, Theme } from '@mui/material/styles';
export interface ListCardProps<T> {
title: string;
icon?: React.ReactNode;
action?: React.ReactNode;
items: T[];
renderItem: (item: T, index: number) => React.ReactNode;
isLoading?: boolean;
skeletonCount?: number;
skeletonItem?: React.ReactNode;
emptyMessage?: string;
maxItems?: number;
footer?: React.ReactNode;
onClick?: () => void;
sx?: SxProps<Theme>;
}
/** Card with a list of items, built on WidgetCard. */
export function ListCard<T>({
title,
icon,
action,
items,
renderItem,
isLoading = false,
skeletonCount = 5,
skeletonItem,
emptyMessage = 'Keine Einträge',
maxItems,
footer,
onClick,
sx,
}: ListCardProps<T>) {
const displayItems = maxItems ? items.slice(0, maxItems) : items;
const skeletonContent = skeletonItem ? (
<Box>
{Array.from({ length: skeletonCount }, (_, i) => (
<Box key={i} sx={{ py: 1 }}>
{skeletonItem}
{i < skeletonCount - 1 && <Divider sx={{ mt: 1 }} />}
</Box>
))}
</Box>
) : (
<ItemListSkeleton count={skeletonCount} />
);
return (
<WidgetCard
title={title}
icon={icon}
action={action}
isLoading={isLoading}
skeleton={skeletonContent}
isEmpty={!isLoading && displayItems.length === 0}
emptyMessage={emptyMessage}
footer={footer}
onClick={onClick}
sx={sx}
>
{displayItems.map((item, index) => (
<React.Fragment key={index}>
<Box sx={{ py: 1 }}>
{renderItem(item, index)}
</Box>
{index < displayItems.length - 1 && <Divider />}
</React.Fragment>
))}
</WidgetCard>
);
}

View File

@@ -0,0 +1,19 @@
import React from 'react';
import { Container } from '@mui/material';
export interface PageContainerProps {
children: React.ReactNode;
maxWidth?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | false;
}
/** Standard page layout wrapper with consistent padding and max width. */
export const PageContainer: React.FC<PageContainerProps> = ({
children,
maxWidth = 'lg',
}) => {
return (
<Container maxWidth={maxWidth} sx={{ py: 3 }}>
{children}
</Container>
);
};

View File

@@ -0,0 +1,79 @@
import React from 'react';
import {
Box,
Breadcrumbs,
IconButton,
Typography,
} from '@mui/material';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import { Link, useNavigate } from 'react-router-dom';
export interface BreadcrumbItem {
label: string;
href?: string;
}
export interface PageHeaderProps {
title: string;
subtitle?: string;
breadcrumbs?: BreadcrumbItem[];
actions?: React.ReactNode;
backTo?: string;
}
/** Page title bar with optional breadcrumbs, back button, and action slot. */
export const PageHeader: React.FC<PageHeaderProps> = ({
title,
subtitle,
breadcrumbs,
actions,
backTo,
}) => {
const navigate = useNavigate();
return (
<Box>
{breadcrumbs && breadcrumbs.length > 0 && (
<Breadcrumbs sx={{ mb: 1 }}>
{breadcrumbs.map((item, i) =>
item.href && i < breadcrumbs.length - 1 ? (
<Link
key={i}
to={item.href}
style={{ textDecoration: 'none', color: 'inherit' }}
>
<Typography variant="body2" color="text.secondary" sx={{ '&:hover': { textDecoration: 'underline' } }}>
{item.label}
</Typography>
</Link>
) : (
<Typography key={i} variant="body2" color="text.primary">
{item.label}
</Typography>
)
)}
</Breadcrumbs>
)}
<Box display="flex" alignItems="center" justifyContent="space-between" mb={3}>
<Box display="flex" alignItems="center" gap={1}>
{backTo && (
<IconButton onClick={() => navigate(backTo)} size="small">
<ArrowBackIcon />
</IconButton>
)}
<Box>
<Typography variant="h5" fontWeight={700}>
{title}
</Typography>
{subtitle && (
<Typography variant="body2" color="text.secondary">
{subtitle}
</Typography>
)}
</Box>
</Box>
{actions && <Box>{actions}</Box>}
</Box>
</Box>
);
};

View File

@@ -0,0 +1,106 @@
import React from 'react';
import { Box, Paper, Skeleton } from '@mui/material';
/** Skeleton for a stat card: label, large value, and circular icon. */
export const StatSkeleton: React.FC = () => (
<Box display="flex" alignItems="center">
<Box sx={{ flex: 1.618 }}>
<Skeleton animation="wave" width="40%" height={14} sx={{ mb: 1 }} />
<Skeleton animation="wave" width="60%" height={32} />
</Box>
<Box sx={{ flex: 1, display: 'flex', justifyContent: 'center' }}>
<Skeleton animation="wave" variant="circular" width={56} height={56} />
</Box>
</Box>
);
/** Skeleton for a row of chips. */
export const ChipListSkeleton: React.FC = () => (
<Box display="flex" gap={1} flexWrap="wrap">
<Skeleton animation="wave" variant="rounded" width={80} height={24} />
<Skeleton animation="wave" variant="rounded" width={100} height={24} />
<Skeleton animation="wave" variant="rounded" width={60} height={24} />
<Skeleton animation="wave" variant="rounded" width={90} height={24} />
</Box>
);
export interface ItemListSkeletonProps {
count?: number;
}
/** Skeleton for a list of items with avatar and two text lines. */
export const ItemListSkeleton: React.FC<ItemListSkeletonProps> = ({ count = 5 }) => (
<Box>
{Array.from({ length: count }, (_, i) => (
<Box key={i} display="flex" alignItems="center" gap={1.5} sx={{ py: 1 }}>
<Skeleton animation="wave" variant="circular" width={10} height={10} />
<Skeleton animation="wave" width="70%" height={16} />
<Skeleton animation="wave" width="40%" height={16} />
</Box>
))}
</Box>
);
/** Skeleton for a quick-action form. */
export const FormSkeleton: React.FC = () => (
<Box display="flex" flexDirection="column" gap={1.5}>
<Skeleton animation="wave" variant="rounded" height={40} />
<Skeleton animation="wave" variant="rounded" height={40} />
<Skeleton animation="wave" variant="rounded" height={36} width="30%" sx={{ alignSelf: 'flex-end' }} />
</Box>
);
export interface TableSkeletonProps {
rows?: number;
columns?: number;
}
/** Skeleton for a data table with header and body rows. */
export const TableSkeleton: React.FC<TableSkeletonProps> = ({ rows = 5, columns = 4 }) => (
<Box>
<Box display="flex" gap={2} sx={{ mb: 1.5 }}>
{Array.from({ length: columns }, (_, i) => (
<Skeleton key={i} animation="wave" width={`${100 / columns}%`} height={14} />
))}
</Box>
{Array.from({ length: rows }, (_, r) => (
<Box key={r} display="flex" gap={2} sx={{ py: 0.75 }}>
{Array.from({ length: columns }, (_, c) => (
<Skeleton key={c} animation="wave" width={`${100 / columns}%`} height={18} />
))}
</Box>
))}
</Box>
);
export interface InfoGridSkeletonProps {
rows?: number;
}
/** Skeleton for a label-value info grid. */
export const InfoGridSkeleton: React.FC<InfoGridSkeletonProps> = ({ rows = 4 }) => (
<Box>
{Array.from({ length: rows }, (_, i) => (
<Box key={i} display="flex" gap={2} sx={{ py: 1 }}>
<Skeleton animation="wave" width="30%" height={16} />
<Skeleton animation="wave" width="60%" height={16} />
</Box>
))}
</Box>
);
export interface SummaryCardsSkeletonProps {
count?: number;
}
/** Skeleton for a grid of summary stat cards. */
export const SummaryCardsSkeleton: React.FC<SummaryCardsSkeletonProps> = ({ count = 4 }) => (
<Box display="grid" gridTemplateColumns="repeat(auto-fill, minmax(140px, 1fr))" gap={2}>
{Array.from({ length: count }, (_, i) => (
<Paper key={i} variant="outlined" sx={{ p: 2 }}>
<Skeleton animation="wave" width="50%" height={14} sx={{ mb: 1 }} />
<Skeleton animation="wave" width="70%" height={24} />
</Paper>
))}
</Box>
);

View File

@@ -0,0 +1,81 @@
import React from 'react';
import { Box, Card, CardActionArea, CardContent, Typography } from '@mui/material';
import { GOLDEN_RATIO } from '../../theme/theme';
import { StatSkeleton } from './SkeletonPresets';
export interface StatCardProps {
title: string;
value: string | number;
icon: React.ReactNode;
color?: string;
trend?: { value: number; label?: string };
isLoading?: boolean;
onClick?: () => void;
}
/** Stat display card with golden-ratio proportioned layout. */
export const StatCard: React.FC<StatCardProps> = ({
title,
value,
icon,
color = 'primary.main',
trend,
isLoading = false,
onClick,
}) => {
const content = (
<CardContent sx={{ p: 2.5, '&:last-child': { pb: 2.5 } }}>
{isLoading ? (
<StatSkeleton />
) : (
<Box display="flex" alignItems="center">
<Box sx={{ flex: GOLDEN_RATIO }}>
<Typography variant="caption" textTransform="uppercase" color="text.secondary">
{title}
</Typography>
<Typography variant="h4" fontWeight={700}>
{value}
</Typography>
{trend && (
<Typography
variant="caption"
color={trend.value >= 0 ? 'success.main' : 'error.main'}
>
{trend.value >= 0 ? '↑' : '↓'} {Math.abs(trend.value)}%
{trend.label && ` ${trend.label}`}
</Typography>
)}
</Box>
<Box sx={{ flex: 1, display: 'flex', justifyContent: 'center' }}>
<Box
sx={{
width: 56,
height: 56,
borderRadius: '50%',
bgcolor: `${color}15`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color,
}}
>
{icon}
</Box>
</Box>
</Box>
)}
</CardContent>
);
return (
<Card sx={{ height: '100%' }}>
{onClick ? (
<CardActionArea onClick={onClick} sx={{ height: '100%' }}>
{content}
</CardActionArea>
) : (
content
)}
</Card>
);
};

View File

@@ -0,0 +1,33 @@
import React from 'react';
import { Chip } from '@mui/material';
export type ChipColor = 'default' | 'primary' | 'secondary' | 'error' | 'info' | 'success' | 'warning';
export interface StatusChipProps {
status: string;
colorMap: Record<string, ChipColor>;
labelMap: Record<string, string>;
size?: 'small' | 'medium';
variant?: 'filled' | 'outlined';
icon?: React.ReactElement;
}
/** Consistent status chip with configurable color and label maps. */
export const StatusChip: React.FC<StatusChipProps> = ({
status,
colorMap,
labelMap,
size = 'small',
variant = 'filled',
icon,
}) => {
return (
<Chip
label={labelMap[status] || status}
color={colorMap[status] || 'default'}
size={size}
variant={variant}
icon={icon}
/>
);
};

View File

@@ -0,0 +1,50 @@
import React from 'react';
import { Box, Paper, Typography } from '@mui/material';
import { SummaryCardsSkeleton } from './SkeletonPresets';
export interface SummaryStat {
label: string;
value: string | number;
color?: string;
onClick?: () => void;
}
export interface SummaryCardsProps {
stats: SummaryStat[];
isLoading?: boolean;
}
/** Mini stat cards displayed in a responsive grid row. */
export const SummaryCards: React.FC<SummaryCardsProps> = ({
stats,
isLoading = false,
}) => {
if (isLoading) {
return <SummaryCardsSkeleton count={stats.length || 4} />;
}
return (
<Box display="grid" gridTemplateColumns="repeat(auto-fit, minmax(160px, 1fr))" gap={2}>
{stats.map((stat, i) => (
<Paper
key={i}
variant="outlined"
onClick={stat.onClick}
sx={{
p: 2,
textAlign: 'center',
cursor: stat.onClick ? 'pointer' : 'default',
'&:hover': stat.onClick ? { bgcolor: 'action.hover' } : {},
}}
>
<Typography variant="h4" sx={{ color: stat.color || 'text.primary', fontWeight: 700 }}>
{stat.value}
</Typography>
<Typography variant="body2" color="text.secondary">
{stat.label}
</Typography>
</Paper>
))}
</Box>
);
};

View File

@@ -0,0 +1,14 @@
import React from 'react';
import { Box } from '@mui/material';
export interface TabPanelProps {
children: React.ReactNode;
value: number;
index: number;
}
/** Consistent tab content wrapper. Renders only when active. */
export const TabPanel: React.FC<TabPanelProps> = ({ children, value, index }) => {
if (value !== index) return null;
return <Box sx={{ pt: 3 }}>{children}</Box>;
};

View File

@@ -0,0 +1,144 @@
import React from 'react';
import {
Box,
Card,
CardActionArea,
CardContent,
Divider,
Skeleton,
Typography,
Alert,
} from '@mui/material';
import InboxIcon from '@mui/icons-material/Inbox';
import type { SxProps, Theme } from '@mui/material/styles';
export interface WidgetCardProps {
title: string;
icon?: React.ReactNode;
action?: React.ReactNode;
isLoading?: boolean;
isError?: boolean;
errorMessage?: string;
isEmpty?: boolean;
emptyMessage?: string;
emptyIcon?: React.ReactNode;
onClick?: () => void;
skeleton?: React.ReactNode;
children?: React.ReactNode;
noPadding?: boolean;
footer?: React.ReactNode;
sx?: SxProps<Theme>;
}
/** Universal dashboard widget wrapper with loading/error/empty states. */
export const WidgetCard: React.FC<WidgetCardProps> = ({
title,
icon,
action,
isLoading = false,
isError = false,
errorMessage = 'Fehler beim Laden',
isEmpty = false,
emptyMessage = 'Keine Einträge',
emptyIcon,
onClick,
skeleton,
children,
noPadding = false,
footer,
sx,
}) => {
const header = (
<Box
sx={noPadding ? { px: 2.5, pt: 2.5 } : undefined}
>
<Box display="flex" alignItems="center" justifyContent="space-between" mb={2}>
<Box display="flex" alignItems="center" gap={1}>
{icon}
<Typography variant="subtitle1" fontWeight={600}>
{title}
</Typography>
</Box>
{action}
</Box>
<Divider sx={{ mb: 2 }} />
</Box>
);
const renderContent = () => {
if (isLoading) {
return skeleton ?? (
<Box>
<Skeleton animation="wave" height={20} sx={{ mb: 1 }} />
<Skeleton animation="wave" height={20} sx={{ mb: 1 }} />
<Skeleton animation="wave" height={20} width="60%" />
</Box>
);
}
if (isError) {
return (
<Alert severity="error" variant="outlined" sx={{ border: 'none' }}>
{errorMessage}
</Alert>
);
}
if (isEmpty) {
return (
<Box
display="flex"
flexDirection="column"
alignItems="center"
justifyContent="center"
py={4}
color="text.secondary"
>
{emptyIcon ?? <InboxIcon sx={{ fontSize: 40, mb: 1, opacity: 0.5 }} />}
<Typography variant="body2" color="text.secondary">
{emptyMessage}
</Typography>
</Box>
);
}
return children;
};
const cardContent = (
<>
{header}
<Box sx={noPadding ? { px: 0 } : undefined}>
{renderContent()}
</Box>
{footer && (
<>
<Divider sx={{ mt: 2 }} />
<Box sx={{ pt: 1.5, ...(noPadding ? { px: 2.5, pb: 2.5 } : {}) }}>
{footer}
</Box>
</>
)}
</>
);
const cardContentSx = noPadding
? { p: 0, '&:last-child': { pb: 0 } }
: { p: 2.5, '&:last-child': { pb: 2.5 } };
return (
<Card sx={{ height: '100%', ...sx }}>
{onClick ? (
<CardActionArea onClick={onClick} sx={{ height: '100%' }}>
<CardContent sx={cardContentSx}>
{cardContent}
</CardContent>
</CardActionArea>
) : (
<CardContent sx={cardContentSx}>
{cardContent}
</CardContent>
)}
</Card>
);
};

View File

@@ -0,0 +1,33 @@
export { WidgetCard } from './WidgetCard';
export type { WidgetCardProps } from './WidgetCard';
export { StatCard } from './StatCard';
export type { StatCardProps } from './StatCard';
export { ListCard } from './ListCard';
export type { ListCardProps } from './ListCard';
export { FormCard } from './FormCard';
export type { FormCardProps } from './FormCard';
export { PageHeader } from './PageHeader';
export type { PageHeaderProps, BreadcrumbItem } from './PageHeader';
export { PageContainer } from './PageContainer';
export type { PageContainerProps } from './PageContainer';
export { FormLayout } from './FormLayout';
export type { FormLayoutProps } from './FormLayout';
export { DetailLayout } from './DetailLayout';
export type { DetailLayoutProps, TabDef } from './DetailLayout';
export { TabPanel } from './TabPanel';
export type { TabPanelProps } from './TabPanel';
export { DataTable } from './DataTable';
export type { DataTableProps, Column } from './DataTable';
export { FilterBar } from './FilterBar';
export type { FilterBarProps } from './FilterBar';
export { InfoGrid } from './InfoGrid';
export type { InfoGridProps, InfoField } from './InfoGrid';
export { SummaryCards } from './SummaryCards';
export type { SummaryCardsProps, SummaryStat } from './SummaryCards';
export { ConfirmDialog } from './ConfirmDialog';
export type { ConfirmDialogProps } from './ConfirmDialog';
export { FormDialog } from './FormDialog';
export type { FormDialogProps } from './FormDialog';
export { StatusChip } from './StatusChip';
export type { StatusChipProps, ChipColor } from './StatusChip';
export * from './SkeletonPresets';