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 { useState, useCallback } from 'react';
import { import {
Box, Paper, Typography, TextField, Button, Alert, Box, Paper, Typography, TextField, Button, Alert,
Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions,
CircularProgress, Divider, CircularProgress, Divider,
} from '@mui/material'; } from '@mui/material';
import DeleteSweepIcon from '@mui/icons-material/DeleteSweep'; import DeleteSweepIcon from '@mui/icons-material/DeleteSweep';
import RestartAltIcon from '@mui/icons-material/RestartAlt'; import RestartAltIcon from '@mui/icons-material/RestartAlt';
import { api } from '../../services/api'; import { api } from '../../services/api';
import { useNotification } from '../../contexts/NotificationContext'; import { useNotification } from '../../contexts/NotificationContext';
import { ConfirmDialog } from '../templates';
interface CleanupSection { interface CleanupSection {
key: string; key: string;
@@ -193,31 +193,21 @@ export default function DataManagementTab() {
); );
})} })}
<Dialog open={!!confirmDialog} onClose={() => !deleting && setConfirmDialog(null)}> <ConfirmDialog
<DialogTitle>Daten loeschen?</DialogTitle> open={!!confirmDialog}
<DialogContent> onClose={() => setConfirmDialog(null)}
<DialogContentText> onConfirm={handleDelete}
{confirmDialog && ( title="Daten loeschen?"
message={confirmDialog ? (
<> <>
<strong>{confirmDialog.count}</strong> {confirmDialog.count === 1 ? 'Eintrag' : 'Eintraege'} aus <strong>{confirmDialog.label}</strong> werden <strong>{confirmDialog.count}</strong> {confirmDialog.count === 1 ? 'Eintrag' : 'Eintraege'} aus <strong>{confirmDialog.label}</strong> werden
unwiderruflich geloescht. Dieser Vorgang kann nicht rueckgaengig gemacht werden. unwiderruflich geloescht. Dieser Vorgang kann nicht rueckgaengig gemacht werden.
</> </>
)} ) : ''}
</DialogContentText> confirmLabel="Endgueltig loeschen"
</DialogContent> confirmColor="error"
<DialogActions> isLoading={deleting}
<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>
{/* ---- Reset / Truncate sections ---- */} {/* ---- Reset / Truncate sections ---- */}
<Divider sx={{ my: 4 }} /> <Divider sx={{ my: 4 }} />
@@ -268,31 +258,21 @@ export default function DataManagementTab() {
); );
})} })}
<Dialog open={!!resetConfirmDialog} onClose={() => !resetDeleting && setResetConfirmDialog(null)}> <ConfirmDialog
<DialogTitle>Daten zuruecksetzen?</DialogTitle> open={!!resetConfirmDialog}
<DialogContent> onClose={() => setResetConfirmDialog(null)}
<DialogContentText> onConfirm={handleResetDelete}
{resetConfirmDialog && ( title="Daten zuruecksetzen?"
message={resetConfirmDialog ? (
<> <>
<strong>{resetConfirmDialog.count}</strong> {resetConfirmDialog.count === 1 ? 'Eintrag' : 'Eintraege'} aus <strong>{resetConfirmDialog.label}</strong> werden <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. unwiderruflich geloescht und die Nummerierung auf 1 zurueckgesetzt. Dieser Vorgang kann nicht rueckgaengig gemacht werden.
</> </>
)} ) : ''}
</DialogContentText> confirmLabel="Endgueltig zuruecksetzen"
</DialogContent> confirmColor="error"
<DialogActions> isLoading={resetDeleting}
<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>
</Box> </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 { useQuery } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { MonitorHeartOutlined } from '@mui/icons-material'; import { MonitorHeartOutlined } from '@mui/icons-material';
import { adminApi } from '../../services/admin'; import { adminApi } from '../../services/admin';
import { useCountUp } from '../../hooks/useCountUp'; import { useCountUp } from '../../hooks/useCountUp';
import { usePermissionContext } from '../../contexts/PermissionContext'; import { usePermissionContext } from '../../contexts/PermissionContext';
import { WidgetCard } from '../templates/WidgetCard';
import { StatSkeleton } from '../templates/SkeletonPresets';
function AdminStatusWidget() { function AdminStatusWidget() {
const { hasPermission } = usePermissionContext(); const { hasPermission } = usePermissionContext();
@@ -29,18 +31,13 @@ function AdminStatusWidget() {
const color = allUp ? 'success' : majorityDown ? 'error' : 'warning'; const color = allUp ? 'success' : majorityDown ? 'error' : 'warning';
return ( return (
<Card <WidgetCard
sx={{ cursor: 'pointer', '&:hover': { boxShadow: 4 } }} title="Service Status"
icon={<MonitorHeartOutlined color={color} />}
isLoading={!data}
skeleton={<StatSkeleton />}
onClick={() => navigate('/admin')} 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 }}> <Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.5, mb: 1 }}>
<Typography variant="h3" component="span" sx={{ fontWeight: 700 }}> <Typography variant="h3" component="span" sx={{ fontWeight: 700 }}>
{up} {up}
@@ -59,8 +56,7 @@ function AdminStatusWidget() {
size="small" size="small"
variant="outlined" variant="outlined"
/> />
</CardContent> </WidgetCard>
</Card>
); );
} }

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 { Build } from '@mui/icons-material';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { ausruestungsanfrageApi } from '../../services/ausruestungsanfrage'; import { ausruestungsanfrageApi } from '../../services/ausruestungsanfrage';
import type { AusruestungWidgetOverview } from '../../types/ausruestungsanfrage.types'; import type { AusruestungWidgetOverview } from '../../types/ausruestungsanfrage.types';
import { WidgetCard } from '../templates/WidgetCard';
import { ChipListSkeleton } from '../templates/SkeletonPresets';
function AusruestungsanfrageWidget() { function AusruestungsanfrageWidget() {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -15,59 +17,32 @@ function AusruestungsanfrageWidget() {
retry: 1, retry: 1,
}); });
if (isLoading) { const hasAny = (overview?.total_count ?? 0) > 0;
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;
return ( return (
<Card <WidgetCard
sx={{ cursor: 'pointer', '&:hover': { bgcolor: 'action.hover' } }} 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')} 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' }}> <Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
{overview.unhandled_count > 0 && ( {overview && overview.unhandled_count > 0 && (
<Chip label={`${overview.unhandled_count} Neu`} size="small" color="error" variant="outlined" /> <Chip label={`${overview.unhandled_count} Neu`} size="small" color="error" variant="outlined" />
)} )}
{overview.pending_count > 0 && ( {overview && overview.pending_count > 0 && (
<Chip label={`${overview.pending_count} Offen`} size="small" color="warning" variant="outlined" /> <Chip label={`${overview.pending_count} Offen`} size="small" color="warning" variant="outlined" />
)} )}
{overview.approved_count > 0 && ( {overview && overview.approved_count > 0 && (
<Chip label={`${overview.approved_count} Genehmigt`} size="small" color="info" variant="outlined" /> <Chip label={`${overview.approved_count} Genehmigt`} size="small" color="info" variant="outlined" />
)} )}
</Box> </Box>
)} </WidgetCard>
</CardContent>
</Card>
); );
} }

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 { Campaign } from '@mui/icons-material';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { bannerApi } from '../../services/banners'; import { bannerApi } from '../../services/banners';
import type { BannerLevel } from '../../types/banner.types'; import type { BannerLevel } from '../../types/banner.types';
import { WidgetCard } from '../templates/WidgetCard';
const SEVERITY_COLOR: Record<BannerLevel, string> = { const SEVERITY_COLOR: Record<BannerLevel, string> = {
info: '#1976d2', info: '#1976d2',
@@ -23,12 +24,10 @@ export default function BannerWidget() {
if (widgetBanners.length === 0) return null; if (widgetBanners.length === 0) return null;
return ( return (
<Card> <WidgetCard
<CardContent> title="Mitteilungen"
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}> icon={<Campaign color="primary" />}
<Campaign color="primary" /> >
<Typography variant="h6">Mitteilungen</Typography>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
{widgetBanners.map(banner => ( {widgetBanners.map(banner => (
<Box <Box
@@ -45,7 +44,6 @@ export default function BannerWidget() {
</Box> </Box>
))} ))}
</Box> </Box>
</CardContent> </WidgetCard>
</Card>
); );
} }

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 { LocalShipping } from '@mui/icons-material';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { bestellungApi } from '../../services/bestellung'; import { bestellungApi } from '../../services/bestellung';
import { BESTELLUNG_STATUS_LABELS, BESTELLUNG_STATUS_COLORS } from '../../types/bestellung.types'; import { BESTELLUNG_STATUS_LABELS, BESTELLUNG_STATUS_COLORS } from '../../types/bestellung.types';
import type { BestellungStatus } from '../../types/bestellung.types'; import type { BestellungStatus } from '../../types/bestellung.types';
import { WidgetCard } from '../templates/WidgetCard';
import { ChipListSkeleton } from '../templates/SkeletonPresets';
function BestellungenWidget() { function BestellungenWidget() {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -26,40 +28,18 @@ function BestellungenWidget() {
.map((s) => ({ status: s, count: openOrders.filter((o) => o.status === s).length })) .map((s) => ({ status: s, count: openOrders.filter((o) => o.status === s).length }))
.filter((s) => s.count > 0); .filter((s) => s.count > 0);
if (isLoading) {
return ( return (
<Card> <WidgetCard
<CardContent> title="Bestellungen"
<Typography variant="h6" gutterBottom>Bestellungen</Typography> icon={<LocalShipping fontSize="small" color="action" />}
<Skeleton variant="rectangular" height={40} /> isLoading={isLoading}
</CardContent> skeleton={<ChipListSkeleton />}
</Card> isError={isError}
); errorMessage="Bestellungen konnten nicht geladen werden."
} isEmpty={!isLoading && !isError && statusCounts.length === 0}
emptyMessage="Keine offenen Bestellungen"
if (isError) { onClick={() => navigate('/bestellungen')}
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 }}> <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{statusCounts.map(({ status, count }) => ( {statusCounts.map(({ status, count }) => (
<Chip <Chip
@@ -71,9 +51,7 @@ function BestellungenWidget() {
/> />
))} ))}
</Box> </Box>
)} </WidgetCard>
</CardContent>
</Card>
); );
} }

View File

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

View File

@@ -1,11 +1,8 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { import {
Card,
CardContent,
Typography, Typography,
Box, Box,
TextField, TextField,
Divider,
CircularProgress, CircularProgress,
InputAdornment, InputAdornment,
Skeleton, Skeleton,
@@ -15,6 +12,7 @@ import { useQuery } from '@tanstack/react-query';
import { bookstackApi } from '../../services/bookstack'; import { bookstackApi } from '../../services/bookstack';
import type { BookStackSearchResult } from '../../types/bookstack.types'; import type { BookStackSearchResult } from '../../types/bookstack.types';
import { safeOpenUrl } from '../../utils/safeOpenUrl'; import { safeOpenUrl } from '../../utils/safeOpenUrl';
import { WidgetCard } from '../templates/WidgetCard';
function stripHtml(html: string): string { function stripHtml(html: string): string {
return html.replace(/<[^>]*>/g, '').trim(); return html.replace(/<[^>]*>/g, '').trim();
@@ -22,7 +20,7 @@ function stripHtml(html: string): string {
const ResultRow: React.FC<{ result: BookStackSearchResult; showDivider: boolean }> = ({ const ResultRow: React.FC<{ result: BookStackSearchResult; showDivider: boolean }> = ({
result, result,
showDivider, showDivider: _showDivider,
}) => { }) => {
const preview = result.preview_html?.content ? stripHtml(result.preview_html.content) : ''; const preview = result.preview_html?.content ? stripHtml(result.preview_html.content) : '';
@@ -58,7 +56,6 @@ const ResultRow: React.FC<{ result: BookStackSearchResult; showDivider: boolean
</Typography> </Typography>
)} )}
</Box> </Box>
{showDivider && <Divider />}
</> </>
); );
}; };
@@ -123,52 +120,31 @@ const BookStackSearchWidget: React.FC = () => {
if (configured === undefined) { if (configured === undefined) {
return ( return (
<Card sx={{ height: '100%' }}> <WidgetCard
<CardContent> title="Wissen — Suche"
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}> icon={<MenuBook color="primary" />}
<MenuBook color="primary" /> isLoading
<Skeleton variant="text" width={160} height={32} /> skeleton={<Skeleton variant="rectangular" height={40} sx={{ borderRadius: 1 }} animation="wave" />}
</Box> />
<Skeleton variant="rectangular" height={40} sx={{ borderRadius: 1 }} />
</CardContent>
</Card>
); );
} }
if (configured === false) { if (configured === false) {
return ( return (
<Card sx={{ height: '100%' }}> <WidgetCard
<CardContent> title="Wissen — Suche"
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}> icon={<MenuBook color="disabled" />}
<MenuBook color="disabled" /> isEmpty
<Typography variant="h6" sx={{ flexGrow: 1 }} color="text.secondary"> emptyMessage="BookStack nicht eingerichtet"
Wissen Suche />
</Typography>
</Box>
<Typography variant="body2" color="text.secondary" sx={{ py: 2, textAlign: 'center' }}>
BookStack nicht eingerichtet
</Typography>
</CardContent>
</Card>
); );
} }
return ( return (
<Card <WidgetCard
sx={{ title="Wissen — Suche"
height: '100%', icon={<MenuBook color="primary" />}
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 Suche
</Typography>
</Box>
<TextField <TextField
fullWidth fullWidth
size="small" size="small"
@@ -186,7 +162,7 @@ const BookStackSearchWidget: React.FC = () => {
{!searching && query.trim() && results.length === 0 && ( {!searching && query.trim() && results.length === 0 && (
<Typography variant="body2" color="text.secondary" sx={{ py: 2, textAlign: 'center' }}> <Typography variant="body2" color="text.secondary" sx={{ py: 2, textAlign: 'center' }}>
Keine Ergebnisse für {query}" Keine Ergebnisse für \u201e{query}\u201c
</Typography> </Typography>
)} )}
@@ -201,8 +177,7 @@ const BookStackSearchWidget: React.FC = () => {
))} ))}
</Box> </Box>
)} )}
</CardContent> </WidgetCard>
</Card>
); );
}; };

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 { AccountBalance } from '@mui/icons-material';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { buchhaltungApi } from '../../services/buchhaltung'; import { buchhaltungApi } from '../../services/buchhaltung';
import { WidgetCard } from '../templates/WidgetCard';
import { ChipListSkeleton } from '../templates/SkeletonPresets';
function fmtEur(val: number) { function fmtEur(val: number) {
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(val); return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(val);
@@ -30,65 +32,30 @@ function BuchhaltungWidget() {
const isLoading = loadingJahre || (!!activeJahr && loadingStats); const isLoading = loadingJahre || (!!activeJahr && loadingStats);
if (isLoading) { const overBudgetCount = stats ? stats.konten_budget.filter(k => k.auslastung_prozent >= 80).length : 0;
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;
return ( return (
<Card sx={{ cursor: 'pointer', '&:hover': { bgcolor: 'action.hover' } }} onClick={() => navigate('/buchhaltung')}> <WidgetCard
<CardContent> title="Buchhaltung"
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1.5 }}> icon={<AccountBalance fontSize="small" color="action" />}
<Typography variant="h6">Buchhaltung</Typography> isLoading={isLoading}
<AccountBalance fontSize="small" color="action" /> skeleton={<ChipListSkeleton />}
</Box> 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 }}> <Box sx={{ mb: 0.5 }}>
<Typography variant="body2" color="text.secondary" component="span">Einnahmen: </Typography> <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> <Typography variant="body2" color="success.main" component="span" fontWeight={600}>{fmtEur(stats?.total_einnahmen ?? 0)}</Typography>
</Box> </Box>
<Box sx={{ mb: 0.5 }}> <Box sx={{ mb: 0.5 }}>
<Typography variant="body2" color="text.secondary" component="span">Ausgaben: </Typography> <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> <Typography variant="body2" color="error.main" component="span" fontWeight={600}>{fmtEur(stats?.total_ausgaben ?? 0)}</Typography>
</Box> </Box>
<Box sx={{ mb: overBudgetCount > 0 ? 1 : 0 }}> <Box sx={{ mb: overBudgetCount > 0 ? 1 : 0 }}>
<Typography variant="body2" color="text.secondary" component="span">Saldo: </Typography> <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> <Typography variant="body2" color={(stats?.saldo ?? 0) >= 0 ? 'success.main' : 'error.main'} component="span" fontWeight={600}>{fmtEur(stats?.saldo ?? 0)}</Typography>
</Box> </Box>
{overBudgetCount > 0 && ( {overBudgetCount > 0 && (
<Chip <Chip
@@ -98,8 +65,7 @@ function BuchhaltungWidget() {
variant="outlined" variant="outlined"
/> />
)} )}
</CardContent> </WidgetCard>
</Card>
); );
} }

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 { AssignmentTurnedIn, Warning } from '@mui/icons-material';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { checklistenApi } from '../../services/checklisten'; import { checklistenApi } from '../../services/checklisten';
import { WidgetCard } from '../templates/WidgetCard';
import { ChipListSkeleton } from '../templates/SkeletonPresets';
function ChecklistWidget() { function ChecklistWidget() {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -16,50 +18,23 @@ function ChecklistWidget() {
const overdueItems = overdue ?? []; const overdueItems = overdue ?? [];
if (isLoading) { const titleAction = !isLoading && !isError && overdueItems.length > 0 ? (
return ( <Chip icon={<Warning />} label={overdueItems.length} color="error" size="small" />
<Card> ) : undefined;
<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>
);
}
return ( return (
<Card sx={{ cursor: 'pointer', '&:hover': { bgcolor: 'action.hover' } }} onClick={() => navigate('/checklisten')}> <WidgetCard
<CardContent> title="Checklisten"
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1.5 }}> icon={<AssignmentTurnedIn fontSize="small" color="action" />}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> action={titleAction}
<Typography variant="h6">Checklisten</Typography> isLoading={isLoading}
{overdueItems.length > 0 && ( skeleton={<ChipListSkeleton />}
<Chip isError={isError}
icon={<Warning />} errorMessage="Checklisten konnten nicht geladen werden."
label={overdueItems.length} isEmpty={!isLoading && !isError && overdueItems.length === 0}
color="error" emptyMessage="Alle Checklisten aktuell"
size="small" onClick={() => navigate('/checklisten')}
/> >
)}
</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 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{overdueItems.slice(0, 5).map((item) => { {overdueItems.slice(0, 5).map((item) => {
const days = Math.ceil((Date.now() - new Date(item.naechste_faellig_am).getTime()) / 86400000); const days = Math.ceil((Date.now() - new Date(item.naechste_faellig_am).getTime()) / 86400000);
@@ -84,9 +59,7 @@ function ChecklistWidget() {
</Typography> </Typography>
)} )}
</Box> </Box>
)} </WidgetCard>
</CardContent>
</Card>
); );
} }

View File

@@ -1,13 +1,9 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { import {
Card,
CardContent,
Typography,
Box,
TextField, TextField,
Button,
Switch, Switch,
FormControlLabel, FormControlLabel,
Typography,
} from '@mui/material'; } from '@mui/material';
import { CalendarMonth } from '@mui/icons-material'; import { CalendarMonth } from '@mui/icons-material';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
@@ -15,6 +11,7 @@ import { eventsApi } from '../../services/events';
import type { CreateVeranstaltungInput } from '../../types/events.types'; import type { CreateVeranstaltungInput } from '../../types/events.types';
import { useNotification } from '../../contexts/NotificationContext'; import { useNotification } from '../../contexts/NotificationContext';
import { usePermissionContext } from '../../contexts/PermissionContext'; import { usePermissionContext } from '../../contexts/PermissionContext';
import { FormCard } from '../templates/FormCard';
function toDatetimeLocal(date: Date): string { function toDatetimeLocal(date: Date): string {
const pad = (n: number) => String(n).padStart(2, '0'); const pad = (n: number) => String(n).padStart(2, '0');
@@ -112,20 +109,13 @@ const EventQuickAddWidget: React.FC = () => {
const datumBisValue = ganztaegig ? datumBis.slice(0, 10) : datumBis; const datumBisValue = ganztaegig ? datumBis.slice(0, 10) : datumBis;
return ( return (
<Card <FormCard
sx={{ title="Veranstaltung"
height: '100%', icon={<CalendarMonth color="primary" />}
transition: 'all 0.3s ease', isSubmitting={mutation.isPending}
'&:hover': { boxShadow: 3 }, onSubmit={handleSubmit}
}} submitLabel="Erstellen"
> >
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<CalendarMonth color="primary" />
<Typography variant="h6">Veranstaltung</Typography>
</Box>
<Box component="form" onSubmit={handleSubmit} sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<TextField <TextField
fullWidth fullWidth
size="small" size="small"
@@ -186,19 +176,7 @@ const EventQuickAddWidget: React.FC = () => {
rows={2} rows={2}
inputProps={{ maxLength: 1000 }} inputProps={{ maxLength: 1000 }}
/> />
</FormCard>
<Button
type="submit"
variant="contained"
size="small"
disabled={!titel.trim() || !datumVon || !datumBis || mutation.isPending}
fullWidth
>
{mutation.isPending ? 'Wird erstellt…' : 'Erstellen'}
</Button>
</Box>
</CardContent>
</Card>
); );
}; };

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 { BugReport } from '@mui/icons-material';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { issuesApi } from '../../services/issues'; import { issuesApi } from '../../services/issues';
import { WidgetCard } from '../templates/WidgetCard';
import { ChipListSkeleton } from '../templates/SkeletonPresets';
function IssueOverviewWidget() { function IssueOverviewWidget() {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -14,53 +16,20 @@ function IssueOverviewWidget() {
retry: 1, 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); const visibleCounts = data.filter((s) => s.count > 0);
if (visibleCounts.length === 0) {
return ( return (
<Card sx={{ cursor: 'pointer' }} onClick={() => navigate('/issues')}> <WidgetCard
<CardContent> title="Issues"
<Typography variant="h6" gutterBottom>Issues</Typography> icon={<BugReport fontSize="small" color="action" />}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: 'text.secondary' }}> isLoading={isLoading}
<BugReport fontSize="small" /> skeleton={<ChipListSkeleton />}
<Typography variant="body2">Keine offenen Issues</Typography> isError={isError}
</Box> errorMessage="Issues konnten nicht geladen werden."
</CardContent> isEmpty={!isLoading && !isError && visibleCounts.length === 0}
</Card> emptyMessage="Keine offenen Issues"
); onClick={() => navigate('/issues')}
} >
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 }}> <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{visibleCounts.map((s) => ( {visibleCounts.map((s) => (
<Chip <Chip
@@ -71,8 +40,7 @@ function IssueOverviewWidget() {
/> />
))} ))}
</Box> </Box>
</CardContent> </WidgetCard>
</Card>
); );
} }

View File

@@ -1,22 +1,19 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { import {
Card,
CardContent,
Typography,
Box,
TextField, TextField,
Button,
MenuItem, MenuItem,
Select, Select,
FormControl, FormControl,
InputLabel, InputLabel,
Skeleton,
SelectChangeEvent, SelectChangeEvent,
} from '@mui/material'; } from '@mui/material';
import { BugReport } from '@mui/icons-material'; import { BugReport } from '@mui/icons-material';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { issuesApi } from '../../services/issues'; import { issuesApi } from '../../services/issues';
import { useNotification } from '../../contexts/NotificationContext'; import { useNotification } from '../../contexts/NotificationContext';
import { FormCard } from '../templates/FormCard';
import { WidgetCard } from '../templates/WidgetCard';
import { FormSkeleton } from '../templates/SkeletonPresets';
const PRIO_OPTIONS = [ const PRIO_OPTIONS = [
{ value: 'niedrig', label: 'Niedrig' }, { value: 'niedrig', label: 'Niedrig' },
@@ -65,28 +62,25 @@ const IssueQuickAddWidget: React.FC = () => {
mutation.mutate(); mutation.mutate();
}; };
if (typesLoading) {
return ( return (
<Card <WidgetCard
sx={{ title="Issue melden"
height: '100%', icon={<BugReport color="primary" />}
transition: 'all 0.3s ease', isLoading
'&:hover': { boxShadow: 3 }, skeleton={<FormSkeleton />}
}} />
> );
<CardContent> }
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<BugReport color="primary" />
<Typography variant="h6">Issue melden</Typography>
</Box>
{typesLoading ? ( return (
<Box> <FormCard
<Skeleton variant="rectangular" height={40} sx={{ mb: 1.5, borderRadius: 1 }} /> title="Issue melden"
<Skeleton variant="rectangular" height={40} sx={{ mb: 1.5, borderRadius: 1 }} /> icon={<BugReport color="primary" />}
<Skeleton variant="rectangular" height={40} sx={{ borderRadius: 1 }} /> isSubmitting={mutation.isPending}
</Box> onSubmit={handleSubmit}
) : ( submitLabel="Melden"
<Box component="form" onSubmit={handleSubmit} sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}> >
<TextField <TextField
fullWidth fullWidth
size="small" size="small"
@@ -128,20 +122,7 @@ const IssueQuickAddWidget: React.FC = () => {
))} ))}
</Select> </Select>
</FormControl> </FormControl>
</FormCard>
<Button
type="submit"
variant="contained"
size="small"
disabled={!titel.trim() || mutation.isPending}
fullWidth
>
{mutation.isPending ? 'Wird erstellt…' : 'Melden'}
</Button>
</Box>
)}
</CardContent>
</Card>
); );
}; };

View File

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

View File

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

View File

@@ -1,13 +1,11 @@
import React from 'react'; import React from 'react';
import { import {
Card,
CardActionArea,
CardContent,
Typography, Typography,
Box, Box,
Chip, Chip,
} from '@mui/material'; } from '@mui/material';
import { SvgIconComponent } from '@mui/icons-material'; import { SvgIconComponent } from '@mui/icons-material';
import { WidgetCard } from '../templates/WidgetCard';
interface ServiceCardProps { interface ServiceCardProps {
title: string; title: string;
@@ -27,52 +25,27 @@ const ServiceCard: React.FC<ServiceCardProps> = ({
const isConnected = status === 'connected'; const isConnected = status === 'connected';
return ( return (
<Card <WidgetCard
sx={{ title={title}
height: '100%', icon={
transition: 'all 0.3s ease',
'&:hover': {
transform: 'translateY(-4px)',
boxShadow: 4,
},
}}
>
<CardActionArea
onClick={onClick}
sx={{
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'stretch',
justifyContent: 'flex-start',
}}
>
<CardContent sx={{ flexGrow: 1, width: '100%' }}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
mb: 2,
}}
>
<Box <Box
sx={{ sx={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
bgcolor: isConnected ? 'success.light' : 'grey.300', bgcolor: isConnected ? 'success.light' : 'grey.300',
borderRadius: '50%', borderRadius: '50%',
p: 1.5, p: 1,
}} }}
> >
<Icon <Icon
sx={{ sx={{
fontSize: 32, fontSize: 24,
color: isConnected ? 'success.dark' : 'grey.600', color: isConnected ? 'success.dark' : 'grey.600',
}} }}
/> />
</Box> </Box>
}
action={
<Box <Box
sx={{ sx={{
width: 12, width: 12,
@@ -81,12 +54,9 @@ const ServiceCard: React.FC<ServiceCardProps> = ({
bgcolor: isConnected ? 'success.main' : 'grey.400', bgcolor: isConnected ? 'success.main' : 'grey.400',
}} }}
/> />
</Box> }
onClick={onClick}
<Typography variant="h6" component="div" gutterBottom> >
{title}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}> <Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
{description} {description}
</Typography> </Typography>
@@ -97,9 +67,7 @@ const ServiceCard: React.FC<ServiceCardProps> = ({
color={isConnected ? 'success' : 'default'} color={isConnected ? 'success' : 'default'}
variant={isConnected ? 'filled' : 'outlined'} variant={isConnected ? 'filled' : 'outlined'}
/> />
</CardContent> </WidgetCard>
</CardActionArea>
</Card>
); );
}; };

View File

@@ -1,74 +1,3 @@
import React from 'react'; // Re-export StatCard from templates for backward compatibility
import { Card, CardContent, Typography, Box } from '@mui/material'; export { StatCard as default } from '../templates/StatCard';
import { SvgIconComponent } from '@mui/icons-material'; export type { StatCardProps } from '../templates/StatCard';
interface StatsCardProps {
title: string;
value: string | number;
icon: SvgIconComponent;
color?: string;
}
const StatsCard: React.FC<StatsCardProps> = ({
title,
value,
icon: Icon,
color = 'primary.main',
}) => {
return (
<Card
sx={{
height: '100%',
transition: 'all 0.3s ease',
'&:hover': {
boxShadow: 3,
},
}}
>
<CardContent>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<Box sx={{ flex: 1 }}>
<Typography
variant="body2"
color="text.secondary"
gutterBottom
sx={{ textTransform: 'uppercase', fontSize: '0.75rem' }}
>
{title}
</Typography>
<Typography variant="h4" component="div" sx={{ fontWeight: 'bold' }}>
{value}
</Typography>
</Box>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: `${color}15`,
borderRadius: '50%',
width: 56,
height: 56,
}}
>
<Icon
sx={{
fontSize: 32,
color: color,
}}
/>
</Box>
</Box>
</CardContent>
</Card>
);
};
export default StatsCard;

View File

@@ -1,14 +1,9 @@
import React from 'react'; import React from 'react';
import { import {
Box, Box,
Card,
CardContent,
CircularProgress,
Chip, Chip,
Divider,
Link, Link,
List, Skeleton,
ListItem,
Typography, Typography,
} from '@mui/material'; } from '@mui/material';
import { CalendarMonth as CalendarMonthIcon } from '@mui/icons-material'; import { CalendarMonth as CalendarMonthIcon } from '@mui/icons-material';
@@ -18,6 +13,8 @@ import { trainingApi } from '../../services/training';
import { eventsApi } from '../../services/events'; import { eventsApi } from '../../services/events';
import type { UebungListItem, UebungTyp } from '../../types/training.types'; import type { UebungListItem, UebungTyp } from '../../types/training.types';
import type { VeranstaltungListItem } from '../../types/events.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 // Color map — matches TYP_DOT_COLOR in Kalender.tsx
@@ -136,72 +133,26 @@ const UpcomingEventsWidget: React.FC = () => {
.slice(0, DISPLAY_LIMIT); .slice(0, DISPLAY_LIMIT);
}, [trainingItems, eventItems, loading]); }, [trainingItems, eventItems, loading]);
// ── Loading state ───────────────────────────────────────────────────────── // ── Render ────────────────────────────────────────────────────────────────
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 ───────────────────────────────────────────────────────────
if (error) { if (error) {
return ( return (
<Card sx={{ height: '100%' }}> <WidgetCard
<CardContent> title="Nächste Termine"
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}> icon={<CalendarMonthIcon fontSize="small" sx={{ color: 'primary.main' }} />}
<CalendarMonthIcon fontSize="small" color="action" /> isError
<Typography variant="h6">Nächste Termine</Typography> errorMessage="Termine konnten nicht geladen werden."
</Box> />
<Typography variant="body2" color="error">
Termine konnten nicht geladen werden.
</Typography>
</CardContent>
</Card>
); );
} }
// ── Main render ───────────────────────────────────────────────────────────
return ( return (
<Card <ListCard
title="Nächste Termine"
icon={<CalendarMonthIcon fontSize="small" sx={{ color: 'primary.main' }} />}
items={entries}
renderItem={(entry) => (
<Box
sx={{ 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>
<Divider sx={{ mb: 1 }} />
{/* Empty state */}
{entries.length === 0 ? (
<Box sx={{ textAlign: 'center', py: 3 }}>
<Typography variant="body2" color="text.secondary">
Keine bevorstehenden Termine
</Typography>
</Box>
) : (
<List disablePadding>
{entries.map((entry, index) => (
<React.Fragment key={entry.id}>
<ListItem
disableGutters
sx={{
py: 1,
display: 'flex', display: 'flex',
alignItems: 'flex-start', alignItems: 'flex-start',
gap: 1.5, gap: 1.5,
@@ -271,18 +222,21 @@ const UpcomingEventsWidget: React.FC = () => {
{entry.title} {entry.title}
</Typography> </Typography>
</Box> </Box>
</ListItem> </Box>
{index < entries.length - 1 && (
<Divider component="li" sx={{ listStyle: 'none' }} />
)} )}
</React.Fragment> isLoading={loading}
))} skeletonCount={5}
</List> skeletonItem={
)} <Box sx={{ display: 'flex', gap: 1.5, alignItems: 'flex-start' }}>
<Skeleton variant="circular" width={10} height={10} sx={{ mt: '4px' }} animation="wave" />
{/* Footer link */} <Box sx={{ flex: 1 }}>
<Divider sx={{ mt: 1, mb: 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' }}> <Box sx={{ textAlign: 'right' }}>
<Link <Link
component={RouterLink} component={RouterLink}
@@ -294,8 +248,8 @@ const UpcomingEventsWidget: React.FC = () => {
Alle Termine Alle Termine
</Link> </Link>
</Box> </Box>
</CardContent> }
</Card> />
); );
}; };

View File

@@ -1,14 +1,9 @@
import React from 'react'; import React from 'react';
import { import {
Box, Box,
Card,
CardContent,
CircularProgress,
Chip, Chip,
Divider,
Link, Link,
List, Skeleton,
ListItem,
Typography, Typography,
} from '@mui/material'; } from '@mui/material';
import { DirectionsCar as DirectionsCarIcon } from '@mui/icons-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 { bookingApi } from '../../services/bookings';
import type { FahrzeugBuchungListItem } from '../../types/booking.types'; import type { FahrzeugBuchungListItem } from '../../types/booking.types';
import { BUCHUNGS_ART_COLORS, BUCHUNGS_ART_LABELS } 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 // Helpers
@@ -55,75 +52,28 @@ const VehicleBookingListWidget: React.FC = () => {
[rawItems], [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) { if (isError) {
return ( return (
<Card sx={{ height: '100%' }}> <WidgetCard
<CardContent> title="Nächste Fahrzeugbuchungen"
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}> icon={<DirectionsCarIcon fontSize="small" sx={{ color: 'primary.main' }} />}
<DirectionsCarIcon fontSize="small" color="action" /> isError
<Typography variant="h6">Nächste Fahrzeugbuchungen</Typography> errorMessage="Buchungen konnten nicht geladen werden."
</Box> />
<Typography variant="body2" color="error">
Buchungen konnten nicht geladen werden.
</Typography>
</CardContent>
</Card>
); );
} }
// ── Main render ───────────────────────────────────────────────────────────
return ( return (
<Card <ListCard
sx={{ title="Nächste Fahrzeugbuchungen"
height: '100%', icon={<DirectionsCarIcon fontSize="small" sx={{ color: 'primary.main' }} />}
transition: 'box-shadow 0.3s ease', items={items}
'&:hover': { boxShadow: 3 }, renderItem={(booking) => {
}}
>
<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>
<Divider sx={{ mb: 1 }} />
{/* Empty state */}
{items.length === 0 ? (
<Box sx={{ textAlign: 'center', py: 3 }}>
<Typography variant="body2" color="text.secondary">
Keine bevorstehenden Buchungen
</Typography>
</Box>
) : (
<List disablePadding>
{items.map((booking, index) => {
const color = BUCHUNGS_ART_COLORS[booking.buchungs_art] ?? '#9e9e9e'; const color = BUCHUNGS_ART_COLORS[booking.buchungs_art] ?? '#9e9e9e';
const label = BUCHUNGS_ART_LABELS[booking.buchungs_art] ?? booking.buchungs_art; const label = BUCHUNGS_ART_LABELS[booking.buchungs_art] ?? booking.buchungs_art;
return ( return (
<React.Fragment key={booking.id}> <Box
<ListItem
disableGutters
sx={{ sx={{
py: 1,
display: 'flex', display: 'flex',
alignItems: 'flex-start', alignItems: 'flex-start',
gap: 1.5, gap: 1.5,
@@ -205,19 +155,22 @@ const VehicleBookingListWidget: React.FC = () => {
{booking.fahrzeug_name} {booking.fahrzeug_name}
</Typography> </Typography>
</Box> </Box>
</ListItem> </Box>
{index < items.length - 1 && (
<Divider component="li" sx={{ listStyle: 'none' }} />
)}
</React.Fragment>
); );
})} }}
</List> isLoading={isLoading}
)} skeletonCount={5}
skeletonItem={
{/* Footer link */} <Box sx={{ display: 'flex', gap: 1.5, alignItems: 'flex-start' }}>
<Divider sx={{ mt: 1, mb: 1 }} /> <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' }}> <Box sx={{ textAlign: 'right' }}>
<Link <Link
component={RouterLink} component={RouterLink}
@@ -229,8 +182,8 @@ const VehicleBookingListWidget: React.FC = () => {
Alle Buchungen Alle Buchungen
</Link> </Link>
</Box> </Box>
</CardContent> }
</Card> />
); );
}; };

View File

@@ -1,16 +1,10 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { import {
Card,
CardContent,
Typography,
Box,
TextField, TextField,
Button,
MenuItem, MenuItem,
Select, Select,
FormControl, FormControl,
InputLabel, InputLabel,
Skeleton,
SelectChangeEvent, SelectChangeEvent,
} from '@mui/material'; } from '@mui/material';
import { DirectionsCar } from '@mui/icons-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 { bookingApi, fetchVehicles } from '../../services/bookings';
import type { CreateBuchungInput } from '../../types/booking.types'; import type { CreateBuchungInput } from '../../types/booking.types';
import { useNotification } from '../../contexts/NotificationContext'; 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 { function toDatetimeLocal(date: Date): string {
const pad = (n: number) => String(n).padStart(2, '0'); const pad = (n: number) => String(n).padStart(2, '0');
@@ -94,29 +91,25 @@ const VehicleBookingQuickAddWidget: React.FC = () => {
mutation.mutate(); mutation.mutate();
}; };
if (vehiclesLoading) {
return ( return (
<Card <WidgetCard
sx={{ title="Fahrzeugbuchung"
height: '100%', icon={<DirectionsCar color="primary" />}
transition: 'all 0.3s ease', isLoading
'&:hover': { boxShadow: 3 }, skeleton={<FormSkeleton />}
}} />
> );
<CardContent> }
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<DirectionsCar color="primary" />
<Typography variant="h6">Fahrzeugbuchung</Typography>
</Box>
{vehiclesLoading ? ( return (
<Box> <FormCard
<Skeleton variant="rectangular" height={40} sx={{ mb: 1.5, borderRadius: 1 }} /> title="Fahrzeugbuchung"
<Skeleton variant="rectangular" height={40} sx={{ mb: 1.5, borderRadius: 1 }} /> icon={<DirectionsCar color="primary" />}
<Skeleton variant="rectangular" height={40} sx={{ mb: 1.5, borderRadius: 1 }} /> isSubmitting={mutation.isPending}
<Skeleton variant="rectangular" height={40} sx={{ borderRadius: 1 }} /> onSubmit={handleSubmit}
</Box> submitLabel="Erstellen"
) : ( >
<Box component="form" onSubmit={handleSubmit} sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<FormControl fullWidth size="small"> <FormControl fullWidth size="small">
<InputLabel>Fahrzeug</InputLabel> <InputLabel>Fahrzeug</InputLabel>
<Select <Select
@@ -172,20 +165,7 @@ const VehicleBookingQuickAddWidget: React.FC = () => {
rows={2} rows={2}
inputProps={{ maxLength: 1000 }} inputProps={{ maxLength: 1000 }}
/> />
</FormCard>
<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>
); );
}; };

View File

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

View File

@@ -1,16 +1,10 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { import {
Card,
CardContent,
Typography,
Box,
TextField, TextField,
Button,
MenuItem, MenuItem,
Select, Select,
FormControl, FormControl,
InputLabel, InputLabel,
Skeleton,
SelectChangeEvent, SelectChangeEvent,
} from '@mui/material'; } from '@mui/material';
import { AddTask } from '@mui/icons-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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { vikunjaApi } from '../../services/vikunja'; import { vikunjaApi } from '../../services/vikunja';
import { useNotification } from '../../contexts/NotificationContext'; 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 VikunjaQuickAddWidget: React.FC = () => {
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
@@ -63,44 +60,34 @@ const VikunjaQuickAddWidget: React.FC = () => {
if (!configured) { if (!configured) {
return ( return (
<Card sx={{ height: '100%' }}> <WidgetCard
<CardContent> title="Aufgabe erstellen"
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}> icon={<AddTask color="disabled" />}
<AddTask color="disabled" /> isEmpty
<Typography variant="h6" color="text.secondary"> emptyMessage="Vikunja nicht eingerichtet"
Aufgabe erstellen />
</Typography> );
</Box> }
<Typography variant="body2" color="text.secondary" sx={{ py: 2, textAlign: 'center' }}>
Vikunja nicht eingerichtet if (projectsLoading) {
</Typography> return (
</CardContent> <WidgetCard
</Card> title="Aufgabe erstellen"
icon={<AddTask color="primary" />}
isLoading
skeleton={<FormSkeleton />}
/>
); );
} }
return ( return (
<Card <FormCard
sx={{ title="Aufgabe erstellen"
height: '100%', icon={<AddTask color="primary" />}
transition: 'all 0.3s ease', isSubmitting={mutation.isPending}
'&:hover': { boxShadow: 3 }, 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>
{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"> <FormControl fullWidth size="small">
<InputLabel>Projekt</InputLabel> <InputLabel>Projekt</InputLabel>
<Select <Select
@@ -135,20 +122,7 @@ const VikunjaQuickAddWidget: React.FC = () => {
value={dueDate} value={dueDate}
onChange={(v) => setDueDate(v)} onChange={(v) => setDueDate(v)}
/> />
</FormCard>
<Button
type="submit"
variant="contained"
size="small"
disabled={!title.trim() || projectId === '' || mutation.isPending}
fullWidth
>
{mutation.isPending ? 'Wird erstellt…' : 'Erstellen'}
</Button>
</Box>
)}
</CardContent>
</Card>
); );
}; };

View File

@@ -1,6 +1,5 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { import {
Alert,
Box, Box,
Button, Button,
Chip, 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';

View File

@@ -195,14 +195,6 @@ function AdminSettings() {
showError('Fehler beim Speichern der PDF-Einstellungen'); showError('Fehler beim Speichern der PDF-Einstellungen');
} }
}; };
const handleLogoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => setPdfLogo(ev.target?.result as string);
reader.readAsDataURL(file);
};
// App logo mutation + handlers // App logo mutation + handlers
const appLogoMutation = useMutation({ const appLogoMutation = useMutation({
mutationFn: (value: string) => settingsApi.update('app_logo', value), mutationFn: (value: string) => settingsApi.update('app_logo', value),

View File

@@ -12,7 +12,6 @@ import {
Dialog, Dialog,
DialogActions, DialogActions,
DialogContent, DialogContent,
DialogContentText,
DialogTitle, DialogTitle,
FormControl, FormControl,
FormControlLabel, FormControlLabel,
@@ -21,12 +20,6 @@ import {
InputLabel, InputLabel,
MenuItem, MenuItem,
Select, Select,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TextField, TextField,
Tooltip, Tooltip,
Typography, Typography,
@@ -41,6 +34,8 @@ import {
} from '@mui/icons-material'; } from '@mui/icons-material';
import DashboardLayout from '../components/dashboard/DashboardLayout'; import DashboardLayout from '../components/dashboard/DashboardLayout';
import ChatAwareFab from '../components/shared/ChatAwareFab'; import ChatAwareFab from '../components/shared/ChatAwareFab';
import { DataTable } from '../components/templates';
import type { Column } from '../components/templates';
import { atemschutzApi } from '../services/atemschutz'; import { atemschutzApi } from '../services/atemschutz';
import { membersService } from '../services/members'; import { membersService } from '../services/members';
import { useNotification } from '../contexts/NotificationContext'; import { useNotification } from '../contexts/NotificationContext';
@@ -54,6 +49,7 @@ import type {
UntersuchungErgebnis, UntersuchungErgebnis,
} from '../types/atemschutz.types'; } from '../types/atemschutz.types';
import { UntersuchungErgebnisLabel } from '../types/atemschutz.types'; import { UntersuchungErgebnisLabel } from '../types/atemschutz.types';
import { ConfirmDialog } from '../components/templates';
import type { MemberListItem } from '../types/member.types'; import type { MemberListItem } from '../types/member.types';
// ── Helpers ────────────────────────────────────────────────────────────────── // ── Helpers ──────────────────────────────────────────────────────────────────
@@ -480,116 +476,86 @@ function Atemschutz() {
)} )}
{/* Table */} {/* Table */}
{!loading && !error && filtered.length > 0 && ( {!loading && !error && filtered.length > 0 && (() => {
<TableContainer> const columns: Column<AtemschutzUebersicht>[] = [
<Table size="small"> { key: 'user_name', label: 'Name', render: (item) => (
<TableHead> <Typography variant="body2" fontWeight={500}>{getDisplayName(item)}</Typography>
<TableRow> )},
<TableCell>Name</TableCell> { key: 'atemschutz_lehrgang', label: 'Lehrgang', align: 'center', render: (item) => (
<TableCell align="center">Lehrgang</TableCell> item.atemschutz_lehrgang ? (
<TableCell>Untersuchung gültig bis</TableCell>
<TableCell>Leistungstest gültig bis</TableCell>
<TableCell align="center">Status</TableCell>
{canWrite && <TableCell align="right">Aktionen</TableCell>}
</TableRow>
</TableHead>
<TableBody>
{filtered.map((item) => {
const untersuchungColor = getValidityColor(
item.untersuchung_gueltig_bis,
item.untersuchung_tage_rest,
90
);
const leistungstestColor = getValidityColor(
item.leistungstest_gueltig_bis,
item.leistungstest_tage_rest,
30
);
return (
<TableRow key={item.id} hover>
<TableCell>
<Typography variant="body2" fontWeight={500}>
{getDisplayName(item)}
</Typography>
</TableCell>
<TableCell align="center">
{item.atemschutz_lehrgang ? (
<Tooltip title={item.lehrgang_datum ? `Lehrgang am ${formatDate(item.lehrgang_datum)}` : 'Lehrgang absolviert'}> <Tooltip title={item.lehrgang_datum ? `Lehrgang am ${formatDate(item.lehrgang_datum)}` : 'Lehrgang absolviert'}>
<Check color="success" fontSize="small" /> <Check color="success" fontSize="small" />
</Tooltip> </Tooltip>
) : ( ) : (
<Close color="disabled" fontSize="small" /> <Close color="disabled" fontSize="small" />
)} )
</TableCell> )},
<TableCell> { key: 'untersuchung_gueltig_bis', label: 'Untersuchung gültig bis', render: (item) => (
<Tooltip <Tooltip title={
title={
item.untersuchung_tage_rest !== null item.untersuchung_tage_rest !== null
? item.untersuchung_tage_rest < 0 ? item.untersuchung_tage_rest < 0
? `Seit ${Math.abs(item.untersuchung_tage_rest)} Tagen abgelaufen` ? `Seit ${Math.abs(item.untersuchung_tage_rest)} Tagen abgelaufen`
: `Noch ${item.untersuchung_tage_rest} Tage gültig` : `Noch ${item.untersuchung_tage_rest} Tage gültig`
: 'Keine Untersuchung eingetragen' : 'Keine Untersuchung eingetragen'
} }>
> <Typography variant="body2" color={getValidityColor(item.untersuchung_gueltig_bis, item.untersuchung_tage_rest, 90)} fontWeight={500}>
<Typography variant="body2" color={untersuchungColor} fontWeight={500}>
{formatDate(item.untersuchung_gueltig_bis)} {formatDate(item.untersuchung_gueltig_bis)}
</Typography> </Typography>
</Tooltip> </Tooltip>
</TableCell> )},
<TableCell> { key: 'leistungstest_gueltig_bis', label: 'Leistungstest gültig bis', render: (item) => (
<Tooltip <Tooltip title={
title={
item.leistungstest_tage_rest !== null item.leistungstest_tage_rest !== null
? item.leistungstest_tage_rest < 0 ? item.leistungstest_tage_rest < 0
? `Seit ${Math.abs(item.leistungstest_tage_rest)} Tagen abgelaufen` ? `Seit ${Math.abs(item.leistungstest_tage_rest)} Tagen abgelaufen`
: `Noch ${item.leistungstest_tage_rest} Tage gültig` : `Noch ${item.leistungstest_tage_rest} Tage gültig`
: 'Kein Leistungstest eingetragen' : 'Kein Leistungstest eingetragen'
} }>
> <Typography variant="body2" color={getValidityColor(item.leistungstest_gueltig_bis, item.leistungstest_tage_rest, 30)} fontWeight={500}>
<Typography variant="body2" color={leistungstestColor} fontWeight={500}>
{formatDate(item.leistungstest_gueltig_bis)} {formatDate(item.leistungstest_gueltig_bis)}
</Typography> </Typography>
</Tooltip> </Tooltip>
</TableCell> )},
<TableCell align="center"> { key: 'einsatzbereit', label: 'Status', align: 'center', render: (item) => (
<Chip <Chip
label={item.einsatzbereit ? 'Einsatzbereit' : 'Nicht einsatzbereit'} label={item.einsatzbereit ? 'Einsatzbereit' : 'Nicht einsatzbereit'}
color={item.einsatzbereit ? 'success' : 'error'} color={item.einsatzbereit ? 'success' : 'error'}
size="small" size="small"
variant="filled" variant="filled"
/> />
</TableCell> )},
{canWrite && ( ];
<TableCell align="right">
if (canWrite) {
columns.push({
key: 'actions', label: 'Aktionen', align: 'right', sortable: false, searchable: false, render: (item) => (
<>
<Tooltip title="Bearbeiten"> <Tooltip title="Bearbeiten">
<Button <Button size="small" onClick={(e) => { e.stopPropagation(); handleOpenEdit(item); }} sx={{ minWidth: 'auto', mr: 0.5 }}>
size="small"
onClick={() => handleOpenEdit(item)}
sx={{ minWidth: 'auto', mr: 0.5 }}
>
<Edit fontSize="small" /> <Edit fontSize="small" />
</Button> </Button>
</Tooltip> </Tooltip>
<Tooltip title="Löschen"> <Tooltip title="Löschen">
<Button <Button size="small" color="error" onClick={(e) => { e.stopPropagation(); setDeleteId(item.id); }} sx={{ minWidth: 'auto' }}>
size="small"
color="error"
onClick={() => setDeleteId(item.id)}
sx={{ minWidth: 'auto' }}
>
<Delete fontSize="small" /> <Delete fontSize="small" />
</Button> </Button>
</Tooltip> </Tooltip>
</TableCell> </>
)} ),
</TableRow> });
}
return (
<DataTable
columns={columns}
data={filtered}
rowKey={(item) => item.id}
emptyMessage={traeger.length === 0 ? 'Keine Atemschutzträger vorhanden' : 'Keine Ergebnisse gefunden'}
searchEnabled={false}
paginationEnabled={false}
/>
); );
})} })()}
</TableBody>
</Table>
</TableContainer>
)}
{/* FAB to create */} {/* FAB to create */}
{canWrite && ( {canWrite && (
@@ -808,29 +774,16 @@ function Atemschutz() {
</Dialog> </Dialog>
{/* ── Delete Confirmation Dialog ──────────────────────────────────── */} {/* ── Delete Confirmation Dialog ──────────────────────────────────── */}
<Dialog open={deleteId !== null} onClose={() => setDeleteId(null)}> <ConfirmDialog
<DialogTitle>Atemschutzträger löschen</DialogTitle> open={deleteId !== null}
<DialogContent> onClose={() => setDeleteId(null)}
<DialogContentText> onConfirm={handleDeleteConfirm}
Soll dieser Atemschutzträger-Eintrag wirklich gelöscht werden? Diese Aktion kann title="Atemschutzträger löschen"
nicht rückgängig gemacht werden. message="Soll dieser Atemschutzträger-Eintrag wirklich gelöscht werden? Diese Aktion kann nicht rückgängig gemacht werden."
</DialogContentText> confirmLabel="Löschen"
</DialogContent> confirmColor="error"
<DialogActions> isLoading={deleteLoading}
<Button onClick={() => setDeleteId(null)} disabled={deleteLoading}> />
Abbrechen
</Button>
<Button
color="error"
variant="contained"
onClick={handleDeleteConfirm}
disabled={deleteLoading}
startIcon={deleteLoading ? <CircularProgress size={16} /> : undefined}
>
Löschen
</Button>
</DialogActions>
</Dialog>
</Container> </Container>
</DashboardLayout> </DashboardLayout>
); );

View File

@@ -9,11 +9,6 @@ import {
Chip, Chip,
CircularProgress, CircularProgress,
Container, Container,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
FormControl, FormControl,
FormControlLabel, FormControlLabel,
Grid, Grid,
@@ -41,14 +36,12 @@ import {
Add as AddIcon, Add as AddIcon,
Build, Build,
CheckCircle, CheckCircle,
Close,
Delete, Delete,
Edit, Edit,
Error as ErrorIcon, Error as ErrorIcon,
LinkRounded, LinkRounded,
PauseCircle, PauseCircle,
RemoveCircle, RemoveCircle,
Save,
Search, Search,
Star, Star,
Warning, Warning,
@@ -68,6 +61,7 @@ import {
import { usePermissions } from '../hooks/usePermissions'; import { usePermissions } from '../hooks/usePermissions';
import { useNotification } from '../contexts/NotificationContext'; import { useNotification } from '../contexts/NotificationContext';
import ChatAwareFab from '../components/shared/ChatAwareFab'; import ChatAwareFab from '../components/shared/ChatAwareFab';
import { ConfirmDialog, FormDialog } from '../components/templates';
// ── Status chip config ──────────────────────────────────────────────────────── // ── Status chip config ────────────────────────────────────────────────────────
@@ -416,18 +410,18 @@ function AusruestungTypenSettings() {
)} )}
{/* Add/Edit dialog */} {/* Add/Edit dialog */}
<Dialog open={dialogOpen} onClose={closeDialog} maxWidth="sm" fullWidth> <FormDialog
<DialogTitle sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> open={dialogOpen}
{editingTyp ? 'Typ bearbeiten' : 'Neuen Typ erstellen'} onClose={closeDialog}
<IconButton onClick={closeDialog} size="small"><Close /></IconButton> onSubmit={handleSave}
</DialogTitle> title={editingTyp ? 'Typ bearbeiten' : 'Neuen Typ erstellen'}
<DialogContent> isSubmitting={isSaving}
>
<TextField <TextField
label="Name *" label="Name *"
fullWidth fullWidth
value={formName} value={formName}
onChange={(e) => setFormName(e.target.value)} onChange={(e) => setFormName(e.target.value)}
sx={{ mt: 1, mb: 2 }}
inputProps={{ maxLength: 100 }} inputProps={{ maxLength: 100 }}
/> />
<TextField <TextField
@@ -437,7 +431,6 @@ function AusruestungTypenSettings() {
rows={2} rows={2}
value={formBeschreibung} value={formBeschreibung}
onChange={(e) => setFormBeschreibung(e.target.value)} onChange={(e) => setFormBeschreibung(e.target.value)}
sx={{ mb: 2 }}
/> />
<TextField <TextField
label="Icon (MUI Icon-Name)" label="Icon (MUI Icon-Name)"
@@ -446,44 +439,19 @@ function AusruestungTypenSettings() {
onChange={(e) => setFormIcon(e.target.value)} onChange={(e) => setFormIcon(e.target.value)}
placeholder="z.B. Build, LocalFireDepartment" placeholder="z.B. Build, LocalFireDepartment"
/> />
</DialogContent> </FormDialog>
<DialogActions>
<Button onClick={closeDialog}>Abbrechen</Button>
<Button
variant="contained"
onClick={handleSave}
disabled={isSaving || !formName.trim()}
startIcon={isSaving ? <CircularProgress size={16} /> : <Save />}
>
Speichern
</Button>
</DialogActions>
</Dialog>
{/* Delete confirmation dialog */} {/* Delete confirmation dialog */}
<Dialog open={deleteDialogOpen} onClose={() => !deleteMutation.isPending && setDeleteDialogOpen(false)}> <ConfirmDialog
<DialogTitle>Typ löschen</DialogTitle> open={deleteDialogOpen}
<DialogContent> onClose={() => setDeleteDialogOpen(false)}
<DialogContentText> onConfirm={() => deletingTyp && deleteMutation.mutate(deletingTyp.id)}
Möchten Sie den Typ &quot;{deletingTyp?.name}&quot; wirklich löschen? title="Typ löschen"
Geräte, denen dieser Typ zugeordnet ist, verlieren die Zuordnung. message={<>Möchten Sie den Typ &quot;{deletingTyp?.name}&quot; wirklich löschen? Geräte, denen dieser Typ zugeordnet ist, verlieren die Zuordnung.</>}
</DialogContentText> confirmLabel="Löschen"
</DialogContent> confirmColor="error"
<DialogActions> isLoading={deleteMutation.isPending}
<Button onClick={() => setDeleteDialogOpen(false)} disabled={deleteMutation.isPending}> />
Abbrechen
</Button>
<Button
color="error"
variant="contained"
onClick={() => deletingTyp && deleteMutation.mutate(deletingTyp.id)}
disabled={deleteMutation.isPending}
startIcon={deleteMutation.isPending ? <CircularProgress size={16} /> : undefined}
>
Löschen
</Button>
</DialogActions>
</Dialog>
</Box> </Box>
); );
} }

View File

@@ -9,7 +9,6 @@ import {
Dialog, Dialog,
DialogActions, DialogActions,
DialogContent, DialogContent,
DialogContentText,
DialogTitle, DialogTitle,
Divider, Divider,
FormControl, FormControl,
@@ -20,14 +19,12 @@ import {
Paper, Paper,
Select, Select,
Stack, Stack,
Tab,
Table, Table,
TableBody, TableBody,
TableCell, TableCell,
TableContainer, TableContainer,
TableHead, TableHead,
TableRow, TableRow,
Tabs,
TextField, TextField,
Tooltip, Tooltip,
Typography, Typography,
@@ -44,14 +41,13 @@ import {
MoreHoriz, MoreHoriz,
PauseCircle, PauseCircle,
RemoveCircle, RemoveCircle,
Save,
Star, Star,
Verified, Verified,
Warning, Warning,
} from '@mui/icons-material'; } from '@mui/icons-material';
import { Link as RouterLink, useNavigate, useParams } from 'react-router-dom'; import { Link as RouterLink, useNavigate, useParams } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import DashboardLayout from '../components/dashboard/DashboardLayout'; import DashboardLayout from '../components/dashboard/DashboardLayout';
import { DetailLayout, ConfirmDialog } from '../components/templates';
import ChatAwareFab from '../components/shared/ChatAwareFab'; import ChatAwareFab from '../components/shared/ChatAwareFab';
import { equipmentApi } from '../services/equipment'; import { equipmentApi } from '../services/equipment';
import { fromGermanDate } from '../utils/dateInput'; import { fromGermanDate } from '../utils/dateInput';
@@ -68,20 +64,6 @@ import {
import { usePermissions } from '../hooks/usePermissions'; import { usePermissions } from '../hooks/usePermissions';
import { useNotification } from '../contexts/NotificationContext'; import { useNotification } from '../contexts/NotificationContext';
// -- Tab Panel ----------------------------------------------------------------
interface TabPanelProps {
children?: React.ReactNode;
index: number;
value: number;
}
const TabPanel: React.FC<TabPanelProps> = ({ children, value, index }) => (
<div role="tabpanel" hidden={value !== index}>
{value === index && <Box sx={{ pt: 3 }}>{children}</Box>}
</div>
);
// -- Status config ------------------------------------------------------------ // -- Status config ------------------------------------------------------------
const STATUS_ICONS: Record<AusruestungStatus, React.ReactElement> = { const STATUS_ICONS: Record<AusruestungStatus, React.ReactElement> = {
@@ -202,7 +184,7 @@ interface UebersichtTabProps {
canWrite: boolean; canWrite: boolean;
} }
const UebersichtTab: React.FC<UebersichtTabProps> = ({ equipment, onStatusUpdated, canChangeStatus, canWrite }) => { const UebersichtTab: React.FC<UebersichtTabProps> = ({ equipment, onStatusUpdated, canChangeStatus, canWrite: _canWrite }) => {
const [statusDialogOpen, setStatusDialogOpen] = useState(false); const [statusDialogOpen, setStatusDialogOpen] = useState(false);
const [newStatus, setNewStatus] = useState<AusruestungStatus>(equipment.status); const [newStatus, setNewStatus] = useState<AusruestungStatus>(equipment.status);
const [bemerkung, setBemerkung] = useState(equipment.status_bemerkung ?? ''); const [bemerkung, setBemerkung] = useState(equipment.status_bemerkung ?? '');
@@ -690,7 +672,6 @@ function AusruestungDetailPage() {
const [equipment, setEquipment] = useState<AusruestungDetail | null>(null); const [equipment, setEquipment] = useState<AusruestungDetail | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState(0);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deleteLoading, setDeleteLoading] = useState(false); const [deleteLoading, setDeleteLoading] = useState(false);
@@ -761,36 +742,46 @@ function AusruestungDetailPage() {
}; };
const canWrite = canManageCategory(equipmentKategorie); const canWrite = canManageCategory(equipmentKategorie);
const subtitle = [ const tabs = [
equipment.kategorie_name, {
equipment.seriennummer ? `SN: ${equipment.seriennummer}` : null, label: 'Übersicht',
].filter(Boolean).join(' · '); content: (
<UebersichtTab
equipment={equipment}
onStatusUpdated={fetchEquipment}
canChangeStatus={canWrite}
canWrite={canWrite}
/>
),
},
{
label: hasOverdue
? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
Wartung <Warning color="error" fontSize="small" />
</Box>
)
: 'Wartung',
content: (
<WartungTab
equipmentId={equipment.id}
wartungslog={equipment.wartungslog ?? []}
onAdded={fetchEquipment}
canWrite={canManageEquipmentMaintenance}
/>
),
},
];
return ( return (
<DashboardLayout> <DashboardLayout>
<Container maxWidth="lg"> <Container maxWidth="lg">
<Button <DetailLayout
startIcon={<ArrowBack />} title={equipment.bezeichnung}
onClick={() => navigate('/ausruestung')} backTo="/ausruestung"
sx={{ mb: 2 }} tabs={tabs}
size="small" actions={
> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
Ausrüstungsübersicht
</Button>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 1 }}>
<Build sx={{ fontSize: 36, color: 'text.secondary' }} />
<Box>
<Typography variant="h4" component="h1">
{equipment.bezeichnung}
</Typography>
{subtitle && (
<Typography variant="subtitle1" color="text.secondary">
{subtitle}
</Typography>
)}
</Box>
<Box sx={{ ml: 'auto', display: 'flex', alignItems: 'center', gap: 1 }}>
<Chip <Chip
icon={STATUS_ICONS[equipment.status]} icon={STATUS_ICONS[equipment.status]}
label={AusruestungStatusLabel[equipment.status]} label={AusruestungStatusLabel[equipment.status]}
@@ -820,74 +811,20 @@ function AusruestungDetailPage() {
</Tooltip> </Tooltip>
)} )}
</Box> </Box>
</Box>
<Box sx={{ borderBottom: 1, borderColor: 'divider', mt: 2 }}>
<Tabs
value={activeTab}
onChange={(_, v) => setActiveTab(v)}
aria-label="Ausrüstung Detailansicht"
variant="scrollable"
scrollButtons="auto"
>
<Tab label="Übersicht" />
<Tab
label={
hasOverdue
? <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
Wartung <Warning color="error" fontSize="small" />
</Box>
: 'Wartung'
} }
/> />
</Tabs>
</Box>
<TabPanel value={activeTab} index={0}>
<UebersichtTab
equipment={equipment}
onStatusUpdated={fetchEquipment}
canChangeStatus={canWrite}
canWrite={canWrite}
/>
</TabPanel>
<TabPanel value={activeTab} index={1}>
<WartungTab
equipmentId={equipment.id}
wartungslog={equipment.wartungslog ?? []}
onAdded={fetchEquipment}
canWrite={canManageEquipmentMaintenance}
/>
</TabPanel>
{/* Delete confirmation dialog */} {/* Delete confirmation dialog */}
<Dialog open={deleteDialogOpen} onClose={() => !deleteLoading && setDeleteDialogOpen(false)}> <ConfirmDialog
<DialogTitle>Gerät löschen</DialogTitle> open={deleteDialogOpen}
<DialogContent> onClose={() => !deleteLoading && setDeleteDialogOpen(false)}
<DialogContentText> onConfirm={handleDelete}
Möchten Sie &apos;{equipment.bezeichnung}&apos; wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden. title="Gerät löschen"
</DialogContentText> message={`Möchten Sie '${equipment.bezeichnung}' wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.`}
</DialogContent> confirmLabel="Löschen"
<DialogActions> confirmColor="error"
<Button isLoading={deleteLoading}
onClick={() => setDeleteDialogOpen(false)} />
disabled={deleteLoading}
autoFocus
>
Abbrechen
</Button>
<Button
color="error"
variant="contained"
onClick={handleDelete}
disabled={deleteLoading}
startIcon={deleteLoading ? <CircularProgress size={16} /> : undefined}
>
Löschen
</Button>
</DialogActions>
</Dialog>
</Container> </Container>
</DashboardLayout> </DashboardLayout>
); );

View File

@@ -16,9 +16,10 @@ import {
TextField, TextField,
Typography, Typography,
} from '@mui/material'; } from '@mui/material';
import { ArrowBack, Save } from '@mui/icons-material'; import { Save } from '@mui/icons-material';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout'; import DashboardLayout from '../components/dashboard/DashboardLayout';
import { PageHeader } from '../components/templates';
import { toGermanDate, fromGermanDate, isValidGermanDate } from '../utils/dateInput'; import { toGermanDate, fromGermanDate, isValidGermanDate } from '../utils/dateInput';
import { equipmentApi } from '../services/equipment'; import { equipmentApi } from '../services/equipment';
import { vehiclesApi } from '../services/vehicles'; import { vehiclesApi } from '../services/vehicles';
@@ -288,7 +289,7 @@ function AusruestungForm() {
<DashboardLayout> <DashboardLayout>
<Container maxWidth="md"> <Container maxWidth="md">
<Alert severity="error">{error}</Alert> <Alert severity="error">{error}</Alert>
<Button startIcon={<ArrowBack />} onClick={() => navigate('/ausruestung')} sx={{ mt: 2 }}> <Button onClick={() => navigate('/ausruestung')} sx={{ mt: 2 }}>
Zurück Zurück
</Button> </Button>
</Container> </Container>
@@ -301,18 +302,15 @@ function AusruestungForm() {
return ( return (
<DashboardLayout> <DashboardLayout>
<Container maxWidth="md"> <Container maxWidth="md">
<Button <PageHeader
startIcon={<ArrowBack />} title={isEditMode ? 'Gerät bearbeiten' : 'Neues Gerät anlegen'}
onClick={() => (isEditMode && id ? navigate(`/ausruestung/${id}`) : navigate('/ausruestung'))} breadcrumbs={[
sx={{ mb: 2 }} { label: 'Ausrüstung', href: '/ausruestung' },
size="small" ...(isEditMode && id ? [{ label: 'Detail', href: `/ausruestung/${id}` }] : []),
> { label: isEditMode ? 'Bearbeiten' : 'Neu' },
{isEditMode ? 'Zurück zur Detailansicht' : 'Ausrüstungsübersicht'} ]}
</Button> backTo={isEditMode && id ? `/ausruestung/${id}` : '/ausruestung'}
/>
<Typography variant="h4" gutterBottom>
{isEditMode ? 'Gerät bearbeiten' : 'Neues Gerät anlegen'}
</Typography>
{saveError && <Alert severity="error" sx={{ mb: 2 }}>{saveError}</Alert>} {saveError && <Alert severity="error" sx={{ mb: 2 }}>{saveError}</Alert>}

View File

@@ -1,8 +1,8 @@
import { useState, useMemo, useEffect } from 'react'; import { useState, useMemo, useEffect } from 'react';
import { import {
Box, Tab, Tabs, Typography, Grid, Button, Chip, Box, Tab, Tabs, Typography, Grid, Chip,
Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper,
TextField, IconButton, MenuItem, TextField, MenuItem,
} from '@mui/material'; } from '@mui/material';
import { Add as AddIcon } from '@mui/icons-material'; import { Add as AddIcon } from '@mui/icons-material';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';

View File

@@ -335,7 +335,7 @@ export default function AusruestungsanfrageArtikelDetail() {
const val = e.target.value ? Number(e.target.value) : ''; const val = e.target.value ? Number(e.target.value) : '';
setMainKat(val); setMainKat(val);
if (val) { if (val) {
const subs = subKategorienOf(val as number); subKategorienOf(val as number);
setForm(f => ({ ...f, kategorie_id: val as number })); setForm(f => ({ ...f, kategorie_id: val as number }));
} else { } else {
setForm(f => ({ ...f, kategorie_id: null })); setForm(f => ({ ...f, kategorie_id: null }));

View File

@@ -14,10 +14,6 @@ import {
TableRow, TableRow,
TextField, TextField,
IconButton, IconButton,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Grid, Grid,
Card, Card,
CardContent, CardContent,
@@ -32,7 +28,6 @@ import {
Tooltip, Tooltip,
} from '@mui/material'; } from '@mui/material';
import { import {
ArrowBack,
Add as AddIcon, Add as AddIcon,
Delete as DeleteIcon, Delete as DeleteIcon,
Edit as EditIcon, Edit as EditIcon,
@@ -58,6 +53,7 @@ import { addPdfHeader, addPdfFooter } from '../utils/pdfExport';
import { BESTELLUNG_STATUS_LABELS, BESTELLUNG_STATUS_COLORS } from '../types/bestellung.types'; import { BESTELLUNG_STATUS_LABELS, BESTELLUNG_STATUS_COLORS } from '../types/bestellung.types';
import type { BestellungStatus, BestellpositionFormData, ErinnerungFormData } from '../types/bestellung.types'; import type { BestellungStatus, BestellpositionFormData, ErinnerungFormData } from '../types/bestellung.types';
import type { AusruestungArtikel, AusruestungEigenschaft } from '../types/ausruestungsanfrage.types'; import type { AusruestungArtikel, AusruestungEigenschaft } from '../types/ausruestungsanfrage.types';
import { ConfirmDialog, StatusChip, PageHeader } from '../components/templates';
// ── Helpers ── // ── Helpers ──
@@ -674,11 +670,11 @@ export default function BestellungDetail() {
return ( return (
<DashboardLayout> <DashboardLayout>
{/* ── Header ── */} {/* ── Header ── */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}> <PageHeader
<IconButton onClick={() => navigate('/bestellungen')}> title={bestellung.bezeichnung}
<ArrowBack /> backTo="/bestellungen"
</IconButton> actions={
<Typography variant="h4" sx={{ flexGrow: 1 }}>{bestellung.bezeichnung}</Typography> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{canExport && !editMode && ( {canExport && !editMode && (
<Tooltip title={bestellung.status === 'entwurf' || bestellung.status === 'wartet_auf_genehmigung' ? 'Export erst nach Genehmigung verfügbar' : 'PDF Export'}> <Tooltip title={bestellung.status === 'entwurf' || bestellung.status === 'wartet_auf_genehmigung' ? 'Export erst nach Genehmigung verfügbar' : 'PDF Export'}>
<span> <span>
@@ -703,11 +699,15 @@ export default function BestellungDetail() {
<Button onClick={cancelEditMode} disabled={isSavingAll}>Abbrechen</Button> <Button onClick={cancelEditMode} disabled={isSavingAll}>Abbrechen</Button>
</> </>
)} )}
<Chip <StatusChip
label={BESTELLUNG_STATUS_LABELS[bestellung.status]} status={bestellung.status}
color={BESTELLUNG_STATUS_COLORS[bestellung.status]} labelMap={BESTELLUNG_STATUS_LABELS}
colorMap={BESTELLUNG_STATUS_COLORS}
size="medium"
/> />
</Box> </Box>
}
/>
{/* ── Info Cards ── */} {/* ── Info Cards ── */}
{editMode ? ( {editMode ? (
@@ -1344,9 +1344,13 @@ export default function BestellungDetail() {
{/* ══════════════════════════════════════════════════════════════════════ */} {/* ══════════════════════════════════════════════════════════════════════ */}
{/* Status Confirmation */} {/* Status Confirmation */}
<Dialog open={statusConfirmTarget != null} onClose={() => { setStatusConfirmTarget(null); setStatusForce(false); }}> <ConfirmDialog
<DialogTitle>Status ändern{statusForce ? ' (manuell)' : ''}</DialogTitle> open={statusConfirmTarget != null}
<DialogContent> onClose={() => { setStatusConfirmTarget(null); setStatusForce(false); }}
onConfirm={() => statusConfirmTarget && updateStatus.mutate({ status: statusConfirmTarget, force: statusForce || undefined })}
title={`Status ändern${statusForce ? ' (manuell)' : ''}`}
message={
<>
<Typography> <Typography>
Status von <strong>{BESTELLUNG_STATUS_LABELS[bestellung.status]}</strong> auf{' '} Status von <strong>{BESTELLUNG_STATUS_LABELS[bestellung.status]}</strong> auf{' '}
<strong>{statusConfirmTarget ? BESTELLUNG_STATUS_LABELS[statusConfirmTarget] : ''}</strong> ändern? <strong>{statusConfirmTarget ? BESTELLUNG_STATUS_LABELS[statusConfirmTarget] : ''}</strong> ändern?
@@ -1361,56 +1365,47 @@ export default function BestellungDetail() {
Nicht alle Positionen haben einen Einzelpreis. Bitte prüfen Sie die Kosten, bevor Sie den Status ändern. Nicht alle Positionen haben einen Einzelpreis. Bitte prüfen Sie die Kosten, bevor Sie den Status ändern.
</Alert> </Alert>
)} )}
</DialogContent> </>
<DialogActions> }
<Button onClick={() => { setStatusConfirmTarget(null); setStatusForce(false); }}>Abbrechen</Button> confirmLabel="Bestätigen"
<Button variant="contained" onClick={() => statusConfirmTarget && updateStatus.mutate({ status: statusConfirmTarget, force: statusForce || undefined })} disabled={updateStatus.isPending}> isLoading={updateStatus.isPending}
Bestätigen />
</Button>
</DialogActions>
</Dialog>
{/* Delete Item Confirmation */} {/* Delete Item Confirmation */}
<Dialog open={deleteItemTarget != null} onClose={() => setDeleteItemTarget(null)}> <ConfirmDialog
<DialogTitle>Position löschen</DialogTitle> open={deleteItemTarget != null}
<DialogContent> onClose={() => setDeleteItemTarget(null)}
<Typography>Soll diese Position wirklich gelöscht werden?</Typography> onConfirm={() => deleteItemTarget != null && deleteItem.mutate(deleteItemTarget)}
</DialogContent> title="Position löschen"
<DialogActions> message="Soll diese Position wirklich gelöscht werden?"
<Button onClick={() => setDeleteItemTarget(null)}>Abbrechen</Button> confirmLabel="Löschen"
<Button color="error" variant="contained" onClick={() => deleteItemTarget != null && deleteItem.mutate(deleteItemTarget)} disabled={deleteItem.isPending}> confirmColor="error"
Löschen isLoading={deleteItem.isPending}
</Button> />
</DialogActions>
</Dialog>
{/* Delete File Confirmation */} {/* Delete File Confirmation */}
<Dialog open={deleteFileTarget != null} onClose={() => setDeleteFileTarget(null)}> <ConfirmDialog
<DialogTitle>Datei löschen</DialogTitle> open={deleteFileTarget != null}
<DialogContent> onClose={() => setDeleteFileTarget(null)}
<Typography>Soll diese Datei wirklich gelöscht werden?</Typography> onConfirm={() => deleteFileTarget != null && deleteFile.mutate(deleteFileTarget)}
</DialogContent> title="Datei löschen"
<DialogActions> message="Soll diese Datei wirklich gelöscht werden?"
<Button onClick={() => setDeleteFileTarget(null)}>Abbrechen</Button> confirmLabel="Löschen"
<Button color="error" variant="contained" onClick={() => deleteFileTarget != null && deleteFile.mutate(deleteFileTarget)} disabled={deleteFile.isPending}> confirmColor="error"
Löschen isLoading={deleteFile.isPending}
</Button> />
</DialogActions>
</Dialog>
{/* Delete Reminder Confirmation */} {/* Delete Reminder Confirmation */}
<Dialog open={deleteReminderTarget != null} onClose={() => setDeleteReminderTarget(null)}> <ConfirmDialog
<DialogTitle>Erinnerung löschen</DialogTitle> open={deleteReminderTarget != null}
<DialogContent> onClose={() => setDeleteReminderTarget(null)}
<Typography>Soll diese Erinnerung wirklich gelöscht werden?</Typography> onConfirm={() => deleteReminderTarget != null && deleteReminder.mutate(deleteReminderTarget)}
</DialogContent> title="Erinnerung löschen"
<DialogActions> message="Soll diese Erinnerung wirklich gelöscht werden?"
<Button onClick={() => setDeleteReminderTarget(null)}>Abbrechen</Button> confirmLabel="Löschen"
<Button color="error" variant="contained" onClick={() => deleteReminderTarget != null && deleteReminder.mutate(deleteReminderTarget)} disabled={deleteReminder.isPending}> confirmColor="error"
Löschen isLoading={deleteReminder.isPending}
</Button> />
</DialogActions>
</Dialog>
</DashboardLayout> </DashboardLayout>
); );
} }

View File

@@ -10,13 +10,13 @@ import {
Tooltip, Tooltip,
} from '@mui/material'; } from '@mui/material';
import { import {
ArrowBack,
Add as AddIcon, Add as AddIcon,
RemoveCircleOutline as RemoveIcon, RemoveCircleOutline as RemoveIcon,
} from '@mui/icons-material'; } from '@mui/icons-material';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout'; import DashboardLayout from '../components/dashboard/DashboardLayout';
import { PageHeader, FormLayout } from '../components/templates';
import { useNotification } from '../contexts/NotificationContext'; import { useNotification } from '../contexts/NotificationContext';
import { bestellungApi } from '../services/bestellung'; import { bestellungApi } from '../services/bestellung';
import type { BestellungFormData, BestellpositionFormData, LieferantFormData, Lieferant } from '../types/bestellung.types'; import type { BestellungFormData, BestellpositionFormData, LieferantFormData, Lieferant } from '../types/bestellung.types';
@@ -75,16 +75,27 @@ export default function BestellungNeu() {
return ( return (
<DashboardLayout> <DashboardLayout>
{/* ── Header ── */} <PageHeader
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}> title="Neue Bestellung"
<IconButton onClick={() => navigate('/bestellungen')}> breadcrumbs={[
<ArrowBack /> { label: 'Bestellungen', href: '/bestellungen' },
</IconButton> { label: 'Neue Bestellung' },
<Typography variant="h5" fontWeight={700}>Neue Bestellung</Typography> ]}
</Box> backTo="/bestellungen"
/>
{/* ── Form ── */} <FormLayout
<Paper sx={{ p: 3, display: 'flex', flexDirection: 'column', gap: 2.5 }}> actions={<>
<Button onClick={() => navigate('/bestellungen')}>Abbrechen</Button>
<Button
variant="contained"
onClick={handleSubmit}
disabled={!orderForm.bezeichnung.trim() || createOrder.isPending}
>
Erstellen
</Button>
</>}
>
<TextField <TextField
label="Bezeichnung" label="Bezeichnung"
required required
@@ -207,17 +218,7 @@ export default function BestellungNeu() {
</Button> </Button>
{/* ── Submit ── */} {/* ── Submit ── */}
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1, mt: 2 }}> </FormLayout>
<Button onClick={() => navigate('/bestellungen')}>Abbrechen</Button>
<Button
variant="contained"
onClick={handleSubmit}
disabled={!orderForm.bezeichnung.trim() || createOrder.isPending}
>
Erstellen
</Button>
</Box>
</Paper>
</DashboardLayout> </DashboardLayout>
); );
} }

View File

@@ -9,13 +9,6 @@ import {
Tabs, Tabs,
Tooltip, Tooltip,
Typography, Typography,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
Chip, Chip,
Button, Button,
Checkbox, Checkbox,
@@ -23,8 +16,6 @@ import {
FormGroup, FormGroup,
LinearProgress, LinearProgress,
Divider, Divider,
TextField,
MenuItem,
} from '@mui/material'; } from '@mui/material';
import { Add as AddIcon, ExpandMore as ExpandMoreIcon, FilterList as FilterListIcon, PictureAsPdf as PdfIcon } from '@mui/icons-material'; import { Add as AddIcon, ExpandMore as ExpandMoreIcon, FilterList as FilterListIcon, PictureAsPdf as PdfIcon } from '@mui/icons-material';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
@@ -38,6 +29,8 @@ import { configApi } from '../services/config';
import { addPdfHeader, addPdfFooter } from '../utils/pdfExport'; import { addPdfHeader, addPdfFooter } from '../utils/pdfExport';
import { BESTELLUNG_STATUS_LABELS, BESTELLUNG_STATUS_COLORS } from '../types/bestellung.types'; import { BESTELLUNG_STATUS_LABELS, BESTELLUNG_STATUS_COLORS } from '../types/bestellung.types';
import type { BestellungStatus, Bestellung } from '../types/bestellung.types'; import type { BestellungStatus, Bestellung } from '../types/bestellung.types';
import { StatusChip, DataTable, SummaryCards } from '../components/templates';
import type { SummaryStat } from '../components/templates';
// ── Helpers ── // ── Helpers ──
@@ -261,18 +254,16 @@ export default function Bestellungen() {
{/* ── Tab 0: Orders ── */} {/* ── Tab 0: Orders ── */}
<TabPanel value={tab} index={0}> <TabPanel value={tab} index={0}>
{/* ── Summary Cards ── */} {/* ── Summary Cards ── */}
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 2, mb: 3 }}> <Box sx={{ mb: 3 }}>
{[ <SummaryCards
{ label: 'Wartet auf Genehmigung', count: orders.filter(o => o.status === 'wartet_auf_genehmigung').length, color: 'warning.main' }, stats={[
{ label: 'Bereit zur Bestellung', count: orders.filter(o => o.status === 'bereit_zur_bestellung').length, color: 'info.main' }, { label: 'Wartet auf Genehmigung', value: orders.filter(o => o.status === 'wartet_auf_genehmigung').length, color: 'warning.main' },
{ label: 'Bestellt', count: orders.filter(o => o.status === 'bestellt').length, color: 'primary.main' }, { label: 'Bereit zur Bestellung', value: orders.filter(o => o.status === 'bereit_zur_bestellung').length, color: 'info.main' },
{ label: 'Lieferung prüfen', count: orders.filter(o => o.status === 'lieferung_pruefen').length, color: 'secondary.main' }, { label: 'Bestellt', value: orders.filter(o => o.status === 'bestellt').length, color: 'primary.main' },
].map(({ label, count, color }) => ( { label: 'Lieferung prüfen', value: orders.filter(o => o.status === 'lieferung_pruefen').length, color: 'secondary.main' },
<Paper variant="outlined" key={label} sx={{ p: 2, textAlign: 'center' }}> ] as SummaryStat[]}
<Typography variant="h4" sx={{ color, fontWeight: 700 }}>{count}</Typography> isLoading={ordersLoading}
<Typography variant="body2" color="text.secondary">{label}</Typography> />
</Paper>
))}
</Box> </Box>
{/* ── Filter ── */} {/* ── Filter ── */}
@@ -335,77 +326,39 @@ export default function Bestellungen() {
</Typography> </Typography>
</Box> </Box>
<TableContainer component={Paper}> <DataTable<Bestellung>
<Table size="small"> columns={[
<TableHead> { key: 'laufende_nummer', label: 'Kennung', width: 90, render: (o) => (
<TableRow> <Typography sx={{ whiteSpace: 'nowrap', fontFamily: 'monospace', fontSize: '0.85rem' }}>{formatKennung(o)}</Typography>
<TableCell>Kennung</TableCell> )},
<TableCell>Bezeichnung</TableCell> { key: 'bezeichnung', label: 'Bezeichnung' },
<TableCell>Lieferant</TableCell> { key: 'lieferant_name', label: 'Lieferant', render: (o) => o.lieferant_name || '' },
<TableCell>Besteller</TableCell> { key: 'besteller_name', label: 'Besteller', render: (o) => o.besteller_name || '' },
<TableCell>Status</TableCell> { key: 'status', label: 'Status', render: (o) => (
<TableCell align="right">Positionen</TableCell> <StatusChip status={o.status} labelMap={BESTELLUNG_STATUS_LABELS} colorMap={BESTELLUNG_STATUS_COLORS} />
<TableCell align="right">Gesamtpreis (brutto)</TableCell> )},
<TableCell>Lieferung</TableCell> { key: 'items_count', label: 'Positionen', align: 'right', render: (o) => o.items_count ?? 0 },
<TableCell>Erstellt am</TableCell> { key: 'total_cost', label: 'Gesamtpreis (brutto)', align: 'right', render: (o) => formatCurrency(calcBrutto(o)) },
</TableRow> { key: 'total_received', label: 'Lieferung', render: (o) => {
</TableHead>
<TableBody>
{ordersLoading ? (
<TableRow><TableCell colSpan={9} align="center">Laden...</TableCell></TableRow>
) : filteredOrders.length === 0 ? (
<TableRow><TableCell colSpan={9} align="center">Keine Bestellungen vorhanden</TableCell></TableRow>
) : (
filteredOrders.map((o) => {
const brutto = calcBrutto(o);
const totalOrdered = o.total_ordered ?? 0; const totalOrdered = o.total_ordered ?? 0;
const totalReceived = o.total_received ?? 0; const totalReceived = o.total_received ?? 0;
const deliveryPct = totalOrdered > 0 ? (totalReceived / totalOrdered) * 100 : 0; const deliveryPct = totalOrdered > 0 ? (totalReceived / totalOrdered) * 100 : 0;
return ( return totalOrdered > 0 ? (
<TableRow <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 100 }}>
key={o.id} <LinearProgress variant="determinate" value={Math.min(deliveryPct, 100)} color={deliveryPct >= 100 ? 'success' : 'primary'} sx={{ flexGrow: 1, height: 6, borderRadius: 3 }} />
hover <Typography variant="caption" sx={{ whiteSpace: 'nowrap' }}>{totalReceived}/{totalOrdered}</Typography>
sx={{ cursor: 'pointer' }}
onClick={() => navigate(`/bestellungen/${o.id}`)}
>
<TableCell sx={{ whiteSpace: 'nowrap', fontFamily: 'monospace', fontSize: '0.85rem' }}>
{formatKennung(o)}
</TableCell>
<TableCell>{o.bezeichnung}</TableCell>
<TableCell>{o.lieferant_name || ''}</TableCell>
<TableCell>{o.besteller_name || ''}</TableCell>
<TableCell>
<Chip
label={BESTELLUNG_STATUS_LABELS[o.status]}
color={BESTELLUNG_STATUS_COLORS[o.status]}
size="small"
/>
</TableCell>
<TableCell align="right">{o.items_count ?? 0}</TableCell>
<TableCell align="right">{formatCurrency(brutto)}</TableCell>
<TableCell sx={{ minWidth: 100 }}>
{totalOrdered > 0 ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<LinearProgress
variant="determinate"
value={Math.min(deliveryPct, 100)}
color={deliveryPct >= 100 ? 'success' : 'primary'}
sx={{ flexGrow: 1, height: 6, borderRadius: 3 }}
/>
<Typography variant="caption" sx={{ whiteSpace: 'nowrap' }}>
{totalReceived}/{totalOrdered}
</Typography>
</Box> </Box>
) : ''} ) : '';
</TableCell> }},
<TableCell>{formatDate(o.erstellt_am)}</TableCell> { key: 'erstellt_am', label: 'Erstellt am', render: (o) => formatDate(o.erstellt_am) },
</TableRow> ]}
); data={filteredOrders}
}) rowKey={(o) => o.id}
)} onRowClick={(o) => navigate(`/bestellungen/${o.id}`)}
</TableBody> isLoading={ordersLoading}
</Table> emptyMessage="Keine Bestellungen vorhanden"
</TableContainer> searchEnabled={false}
/>
{hasPermission('bestellungen:create') && ( {hasPermission('bestellungen:create') && (
<ChatAwareFab onClick={() => navigate('/bestellungen/neu')} aria-label="Neue Bestellung"> <ChatAwareFab onClick={() => navigate('/bestellungen/neu')} aria-label="Neue Bestellung">
@@ -417,45 +370,22 @@ export default function Bestellungen() {
{/* ── Tab 1: Vendors ── */} {/* ── Tab 1: Vendors ── */}
{canManageVendors && ( {canManageVendors && (
<TabPanel value={tab} index={1}> <TabPanel value={tab} index={1}>
<TableContainer component={Paper}> <DataTable
<Table size="small"> columns={[
<TableHead> { key: 'name', label: 'Name' },
<TableRow> { key: 'kontakt_name', label: 'Kontakt', render: (v) => v.kontakt_name || '' },
<TableCell>Name</TableCell> { key: 'email', label: 'E-Mail', render: (v) => v.email ? <a href={`mailto:${v.email}`} onClick={(e) => e.stopPropagation()}>{v.email}</a> : '' },
<TableCell>Kontakt</TableCell> { key: 'telefon', label: 'Telefon', render: (v) => v.telefon || '' },
<TableCell>E-Mail</TableCell> { key: 'website', label: 'Website', render: (v) => v.website ? (
<TableCell>Telefon</TableCell>
<TableCell>Website</TableCell>
</TableRow>
</TableHead>
<TableBody>
{vendorsLoading ? (
<TableRow><TableCell colSpan={5} align="center">Laden...</TableCell></TableRow>
) : vendors.length === 0 ? (
<TableRow><TableCell colSpan={5} align="center">Keine Lieferanten vorhanden</TableCell></TableRow>
) : (
vendors.map((v) => (
<TableRow
key={v.id}
hover
sx={{ cursor: 'pointer' }}
onClick={() => navigate(`/bestellungen/lieferanten/${v.id}`)}
>
<TableCell>{v.name}</TableCell>
<TableCell>{v.kontakt_name || ''}</TableCell>
<TableCell>{v.email ? <a href={`mailto:${v.email}`} onClick={(e) => e.stopPropagation()}>{v.email}</a> : ''}</TableCell>
<TableCell>{v.telefon || ''}</TableCell>
<TableCell>
{v.website ? (
<a href={v.website.startsWith('http') ? v.website : `https://${v.website}`} target="_blank" rel="noopener noreferrer" onClick={(e) => e.stopPropagation()}>{v.website}</a> <a href={v.website.startsWith('http') ? v.website : `https://${v.website}`} target="_blank" rel="noopener noreferrer" onClick={(e) => e.stopPropagation()}>{v.website}</a>
) : ''} ) : '' },
</TableCell> ]}
</TableRow> data={vendors}
)) rowKey={(v) => v.id}
)} onRowClick={(v) => navigate(`/bestellungen/lieferanten/${v.id}`)}
</TableBody> isLoading={vendorsLoading}
</Table> emptyMessage="Keine Lieferanten vorhanden"
</TableContainer> />
<ChatAwareFab onClick={() => navigate('/bestellungen/lieferanten/neu')} aria-label="Lieferant hinzufügen"> <ChatAwareFab onClick={() => navigate('/bestellungen/lieferanten/neu')} aria-label="Lieferant hinzufügen">
<AddIcon /> <AddIcon />

View File

@@ -12,7 +12,6 @@ import {
CircularProgress, CircularProgress,
Alert, Alert,
Switch, Switch,
IconButton,
Chip, Chip,
Stack, Stack,
Card, Card,
@@ -22,7 +21,6 @@ import {
Grid, Grid,
} from '@mui/material'; } from '@mui/material';
import { import {
ArrowBack,
CheckCircle, CheckCircle,
Warning, Warning,
Block, Block,
@@ -30,6 +28,7 @@ import {
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout'; import DashboardLayout from '../components/dashboard/DashboardLayout';
import { PageHeader } from '../components/templates';
import ServiceModePage from '../components/shared/ServiceModePage'; import ServiceModePage from '../components/shared/ServiceModePage';
import GermanDateField from '../components/shared/GermanDateField'; import GermanDateField from '../components/shared/GermanDateField';
import { usePermissionContext } from '../contexts/PermissionContext'; import { usePermissionContext } from '../contexts/PermissionContext';
@@ -243,15 +242,14 @@ function BookingFormPage() {
<ServiceModePage message="Fahrzeugbuchungen befinden sich aktuell im Wartungsmodus." /> <ServiceModePage message="Fahrzeugbuchungen befinden sich aktuell im Wartungsmodus." />
) : ( ) : (
<Container maxWidth="md" sx={{ py: 3 }}> <Container maxWidth="md" sx={{ py: 3 }}>
{/* Page header */} <PageHeader
<Box sx={{ display: 'flex', alignItems: 'center', mb: 3 }}> title={isEdit ? 'Buchung bearbeiten' : 'Neue Buchung'}
<IconButton onClick={() => navigate('/fahrzeugbuchungen')}> breadcrumbs={[
<ArrowBack /> { label: 'Fahrzeugbuchungen', href: '/fahrzeugbuchungen' },
</IconButton> { label: isEdit ? 'Bearbeiten' : 'Neue Buchung' },
<Typography variant="h5" sx={{ ml: 1 }}> ]}
{isEdit ? 'Buchung bearbeiten' : 'Neue Buchung'} backTo="/fahrzeugbuchungen"
</Typography> />
</Box>
{error && ( {error && (
<Alert severity="error" sx={{ mb: 2 }}> <Alert severity="error" sx={{ mb: 2 }}>

View File

@@ -90,6 +90,8 @@ import {
KONTO_ART_LABELS, KONTO_ART_LABELS,
} from '../types/buchhaltung.types'; } from '../types/buchhaltung.types';
import { StatusChip } from '../components/templates';
// ─── helpers ─────────────────────────────────────────────────────────────────── // ─── helpers ───────────────────────────────────────────────────────────────────
function fmtEur(val: number) { function fmtEur(val: number) {
@@ -1392,7 +1394,7 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
{t.typ === 'ausgabe' ? '-' : t.typ === 'einnahme' ? '+' : ''}{fmtEur(t.betrag)} {t.typ === 'ausgabe' ? '-' : t.typ === 'einnahme' ? '+' : ''}{fmtEur(t.betrag)}
</TableCell> </TableCell>
<TableCell> <TableCell>
<Chip label={TRANSAKTION_STATUS_LABELS[t.status]} size="small" color={TRANSAKTION_STATUS_COLORS[t.status]} /> <StatusChip status={t.status} labelMap={TRANSAKTION_STATUS_LABELS} colorMap={TRANSAKTION_STATUS_COLORS} />
</TableCell> </TableCell>
<TableCell> <TableCell>
<Stack direction="row" spacing={0.5}> <Stack direction="row" spacing={0.5}>

View File

@@ -3,8 +3,7 @@ import { useParams, useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { import {
Box, Button, TextField, Typography, Paper, Stack, MenuItem, Select, Box, Button, TextField, Typography, Paper, Stack, MenuItem, Select,
FormControl, InputLabel, Alert, Dialog, DialogTitle, FormControl, InputLabel, Alert, Skeleton, Divider, LinearProgress, Grid,
DialogContent, DialogActions, Skeleton, Divider, LinearProgress, Grid,
ToggleButton, ToggleButtonGroup, ToggleButton, ToggleButtonGroup,
} from '@mui/material'; } from '@mui/material';
import { ArrowBack, Delete, Edit, Save, Cancel } from '@mui/icons-material'; import { ArrowBack, Delete, Edit, Save, Cancel } from '@mui/icons-material';
@@ -12,6 +11,7 @@ import DashboardLayout from '../components/dashboard/DashboardLayout';
import { buchhaltungApi } from '../services/buchhaltung'; import { buchhaltungApi } from '../services/buchhaltung';
import { useNotification } from '../contexts/NotificationContext'; import { useNotification } from '../contexts/NotificationContext';
import type { KontoFormData, BudgetTyp } from '../types/buchhaltung.types'; import type { KontoFormData, BudgetTyp } from '../types/buchhaltung.types';
import { ConfirmDialog } from '../components/templates';
const fmtEur = (n: number) => new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(n); const fmtEur = (n: number) => new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(n);
@@ -290,26 +290,21 @@ export default function BuchhaltungKontoManage() {
</Paper> </Paper>
</Stack> </Stack>
<Dialog open={deleteOpen} onClose={() => setDeleteOpen(false)}> <ConfirmDialog
<DialogTitle>Konto löschen</DialogTitle> open={deleteOpen}
<DialogContent> onClose={() => setDeleteOpen(false)}
onConfirm={() => { deleteMut.mutate(); setDeleteOpen(false); }}
title="Konto löschen"
message={
<Typography> <Typography>
Möchten Sie das Konto <strong>{konto.kontonummer} {konto.bezeichnung}</strong> wirklich löschen? Möchten Sie das Konto <strong>{konto.kontonummer} {konto.bezeichnung}</strong> wirklich löschen?
Diese Aktion kann nicht rückgängig gemacht werden. Diese Aktion kann nicht rückgängig gemacht werden.
</Typography> </Typography>
</DialogContent> }
<DialogActions> confirmLabel="Löschen"
<Button onClick={() => setDeleteOpen(false)}>Abbrechen</Button> confirmColor="error"
<Button isLoading={deleteMut.isPending}
variant="contained" />
color="error"
onClick={() => { deleteMut.mutate(); setDeleteOpen(false); }}
disabled={deleteMut.isPending}
>
Löschen
</Button>
</DialogActions>
</Dialog>
</DashboardLayout> </DashboardLayout>
); );
} }

View File

@@ -23,6 +23,7 @@ import { usePermissionContext } from '../contexts/PermissionContext';
import { useNotification } from '../contexts/NotificationContext'; import { useNotification } from '../contexts/NotificationContext';
import { checklistenApi } from '../services/checklisten'; import { checklistenApi } from '../services/checklisten';
import { CHECKLIST_STATUS_LABELS, CHECKLIST_STATUS_COLORS } from '../types/checklist.types'; import { CHECKLIST_STATUS_LABELS, CHECKLIST_STATUS_COLORS } from '../types/checklist.types';
import { StatusChip } from '../components/templates';
import type { ChecklistAusfuehrungItem } from '../types/checklist.types'; import type { ChecklistAusfuehrungItem } from '../types/checklist.types';
// ── Helpers ── // ── Helpers ──
@@ -255,9 +256,11 @@ export default function ChecklistAusfuehrung() {
{execution.fahrzeug_name ?? execution.ausruestung_name ?? ''} &middot; {formatDate(execution.ausgefuehrt_am ?? execution.created_at)} {execution.fahrzeug_name ?? execution.ausruestung_name ?? ''} &middot; {formatDate(execution.ausgefuehrt_am ?? execution.created_at)}
</Typography> </Typography>
</Box> </Box>
<Chip <StatusChip
label={CHECKLIST_STATUS_LABELS[execution.status]} status={execution.status}
color={CHECKLIST_STATUS_COLORS[execution.status]} labelMap={CHECKLIST_STATUS_LABELS}
colorMap={CHECKLIST_STATUS_COLORS}
size="medium"
/> />
</Box> </Box>

View File

@@ -52,6 +52,8 @@ import {
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout'; import DashboardLayout from '../components/dashboard/DashboardLayout';
import { TabPanel, DataTable } from '../components/templates';
import type { Column } from '../components/templates';
import { usePermissionContext } from '../contexts/PermissionContext'; import { usePermissionContext } from '../contexts/PermissionContext';
import { useNotification } from '../contexts/NotificationContext'; import { useNotification } from '../contexts/NotificationContext';
import { checklistenApi } from '../services/checklisten'; import { checklistenApi } from '../services/checklisten';
@@ -124,14 +126,6 @@ function getDueLabel(nextDue?: string | null, intervall?: string | null): string
return `in ${daysUntil}d fällig`; return `in ${daysUntil}d fällig`;
} }
// ── Tab Panel ──
interface TabPanelProps { children: React.ReactNode; index: number; value: number }
function TabPanel({ children, value, index }: TabPanelProps) {
if (value !== index) return null;
return <Box sx={{ pt: 3 }}>{children}</Box>;
}
// ══════════════════════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════════════════════
// Component // Component
// ══════════════════════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════════════════════
@@ -783,6 +777,17 @@ function HistorieTab({ executions, loading, navigate }: HistorieTabProps) {
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>; if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>;
const columns: Column<ChecklistAusfuehrung>[] = [
{ key: 'fahrzeug_name', label: 'Fahrzeug / Ausrüstung', render: (e) => e.fahrzeug_name || e.ausruestung_name || '' },
{ key: 'vorlage_name', label: 'Vorlage', render: (e) => e.vorlage_name ?? '' },
{ key: 'ausgefuehrt_am', label: 'Datum', render: (e) => formatDate(e.ausgefuehrt_am ?? e.created_at) },
{ key: 'status', label: 'Status', render: (e) => (
<Chip label={CHECKLIST_STATUS_LABELS[e.status]} color={CHECKLIST_STATUS_COLORS[e.status]} size="small" />
)},
{ key: 'ausgefuehrt_von_name', label: 'Ausgeführt von', render: (e) => e.ausgefuehrt_von_name ?? '' },
{ key: 'freigegeben_von_name', label: 'Freigegeben von', render: (e) => e.freigegeben_von_name ?? '' },
];
return ( return (
<Box> <Box>
<Box sx={{ display: 'flex', gap: 2, mb: 2 }}> <Box sx={{ display: 'flex', gap: 2, mb: 2 }}>
@@ -804,38 +809,14 @@ function HistorieTab({ executions, loading, navigate }: HistorieTabProps) {
</FormControl> </FormControl>
</Box> </Box>
<TableContainer component={Paper}> <DataTable
<Table size="small"> columns={columns}
<TableHead> data={filtered}
<TableRow> rowKey={(e) => e.id}
<TableCell>Fahrzeug / Ausrüstung</TableCell> onRowClick={(e) => navigate(`/checklisten/ausfuehrung/${e.id}`)}
<TableCell>Vorlage</TableCell> emptyMessage="Keine Einträge"
<TableCell>Datum</TableCell> searchEnabled={false}
<TableCell>Status</TableCell> />
<TableCell>Ausgeführt von</TableCell>
<TableCell>Freigegeben von</TableCell>
</TableRow>
</TableHead>
<TableBody>
{filtered.length === 0 ? (
<TableRow><TableCell colSpan={6} align="center">Keine Einträge</TableCell></TableRow>
) : (
filtered.map((e) => (
<TableRow key={e.id} hover sx={{ cursor: 'pointer' }} onClick={() => navigate(`/checklisten/ausfuehrung/${e.id}`)}>
<TableCell>{e.fahrzeug_name || e.ausruestung_name || ''}</TableCell>
<TableCell>{e.vorlage_name ?? ''}</TableCell>
<TableCell>{formatDate(e.ausgefuehrt_am ?? e.created_at)}</TableCell>
<TableCell>
<Chip label={CHECKLIST_STATUS_LABELS[e.status]} color={CHECKLIST_STATUS_COLORS[e.status]} size="small" />
</TableCell>
<TableCell>{e.ausgefuehrt_von_name ?? ''}</TableCell>
<TableCell>{e.freigegeben_von_name ?? ''}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
</Box> </Box>
); );
} }

View File

@@ -7,12 +7,6 @@ import {
Button, Button,
Chip, Chip,
Paper, Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TablePagination, TablePagination,
TextField, TextField,
Grid, Grid,
@@ -36,6 +30,7 @@ import {
import { format, parseISO } from 'date-fns'; import { format, parseISO } from 'date-fns';
import { de } from 'date-fns/locale'; import { de } from 'date-fns/locale';
import DashboardLayout from '../components/dashboard/DashboardLayout'; import DashboardLayout from '../components/dashboard/DashboardLayout';
import { DataTable } from '../components/templates';
import { fromGermanDate } from '../utils/dateInput'; import { fromGermanDate } from '../utils/dateInput';
import IncidentStatsChart from '../components/incidents/IncidentStatsChart'; import IncidentStatsChart from '../components/incidents/IncidentStatsChart';
import { import {
@@ -395,99 +390,48 @@ function Einsaetze() {
)} )}
{/* Incident table */} {/* Incident table */}
<Paper> <DataTable<EinsatzListItem>
<TableContainer> columns={[
<Table size="small" aria-label="Einsatzliste"> { key: 'alarm_time', label: 'Datum / Uhrzeit', render: (row) => (
<TableHead> <Typography sx={{ whiteSpace: 'nowrap', fontSize: '0.8125rem' }}>{formatDE(row.alarm_time)}</Typography>
<TableRow> )},
<TableCell sx={{ fontWeight: 700, whiteSpace: 'nowrap' }}>Datum / Uhrzeit</TableCell> { key: 'einsatz_nr', label: 'Nr.', render: (row) => (
<TableCell sx={{ fontWeight: 700 }}>Nr.</TableCell> <Typography sx={{ fontFamily: 'monospace', fontSize: '0.8rem' }}>{row.einsatz_nr}</Typography>
<TableCell sx={{ fontWeight: 700 }}>Einsatzart</TableCell> )},
<TableCell sx={{ fontWeight: 700 }}>Stichwort</TableCell> { key: 'einsatz_art', label: 'Einsatzart', render: (row) => (
<TableCell sx={{ fontWeight: 700 }}>Ort</TableCell> <Chip label={row.einsatz_art} color={ART_CHIP_COLOR[row.einsatz_art]} size="small" sx={{ fontSize: '0.7rem' }} />
<TableCell sx={{ fontWeight: 700, textAlign: 'right' }}>Hilfsfrist</TableCell> )},
<TableCell sx={{ fontWeight: 700, textAlign: 'right' }}>Dauer</TableCell> { key: 'einsatz_stichwort', label: 'Stichwort', render: (row) => (
<TableCell sx={{ fontWeight: 700 }}>Status</TableCell> <Typography sx={{ fontSize: '0.8125rem' }}>{row.einsatz_stichwort ?? '—'}</Typography>
<TableCell sx={{ fontWeight: 700 }}>Einsatzleiter</TableCell> )},
<TableCell sx={{ fontWeight: 700, textAlign: 'right' }}>Kräfte</TableCell> { key: 'strasse', label: 'Ort', render: (row) => (
</TableRow> <Typography sx={{ fontSize: '0.8125rem' }}>{[row.strasse, row.ort].filter(Boolean).join(', ') || '—'}</Typography>
</TableHead> )},
<TableBody> { key: 'hilfsfrist_min', label: 'Hilfsfrist', align: 'right', render: (row) => (
{listLoading <Typography sx={{ fontSize: '0.8125rem', whiteSpace: 'nowrap' }}>{durationLabel(row.hilfsfrist_min)}</Typography>
? Array.from({ length: rowsPerPage > 10 ? 10 : rowsPerPage }).map((_, i) => ( )},
<TableRow key={i}> { key: 'dauer_min', label: 'Dauer', align: 'right', render: (row) => (
{Array.from({ length: 10 }).map((__, j) => ( <Typography sx={{ fontSize: '0.8125rem', whiteSpace: 'nowrap' }}>{durationLabel(row.dauer_min)}</Typography>
<TableCell key={j}> )},
<Skeleton variant="text" /> { key: 'status', label: 'Status', render: (row) => (
</TableCell> <Chip label={EINSATZ_STATUS_LABELS[row.status]} color={STATUS_CHIP_COLOR[row.status]} size="small" sx={{ fontSize: '0.7rem' }} />
))} )},
</TableRow> { key: 'einsatzleiter_name', label: 'Einsatzleiter', render: (row) => (
)) <Typography sx={{ fontSize: '0.8125rem' }}>{row.einsatzleiter_name ?? '—'}</Typography>
: items.length === 0 )},
? ( { key: 'personal_count', label: 'Kräfte', align: 'right', render: (row) => (
<TableRow> <Typography sx={{ fontSize: '0.8125rem' }}>{row.personal_count > 0 ? row.personal_count : '—'}</Typography>
<TableCell colSpan={10} align="center" sx={{ py: 6 }}> )},
<Box sx={{ color: 'text.disabled', fontSize: 48, mb: 1 }}> ]}
<LocalFireDepartment fontSize="inherit" /> data={items}
</Box> rowKey={(row) => row.id}
<Typography variant="body1" color="text.secondary"> onRowClick={(row) => handleRowClick(row.id)}
Keine Einsätze gefunden isLoading={listLoading}
</Typography> emptyMessage="Keine Einsätze gefunden"
</TableCell> emptyIcon={<LocalFireDepartment sx={{ fontSize: 40, mb: 1, opacity: 0.5 }} />}
</TableRow> searchEnabled={false}
) paginationEnabled={false}
: items.map((row) => (
<TableRow
key={row.id}
hover
onClick={() => handleRowClick(row.id)}
sx={{ cursor: 'pointer' }}
>
<TableCell sx={{ whiteSpace: 'nowrap', fontSize: '0.8125rem' }}>
{formatDE(row.alarm_time)}
</TableCell>
<TableCell sx={{ fontFamily: 'monospace', fontSize: '0.8rem' }}>
{row.einsatz_nr}
</TableCell>
<TableCell>
<Chip
label={row.einsatz_art}
color={ART_CHIP_COLOR[row.einsatz_art]}
size="small"
sx={{ fontSize: '0.7rem' }}
/> />
</TableCell>
<TableCell sx={{ fontSize: '0.8125rem' }}>
{row.einsatz_stichwort ?? '—'}
</TableCell>
<TableCell sx={{ fontSize: '0.8125rem' }}>
{[row.strasse, row.ort].filter(Boolean).join(', ') || '—'}
</TableCell>
<TableCell align="right" sx={{ fontSize: '0.8125rem', whiteSpace: 'nowrap' }}>
{durationLabel(row.hilfsfrist_min)}
</TableCell>
<TableCell align="right" sx={{ fontSize: '0.8125rem', whiteSpace: 'nowrap' }}>
{durationLabel(row.dauer_min)}
</TableCell>
<TableCell>
<Chip
label={EINSATZ_STATUS_LABELS[row.status]}
color={STATUS_CHIP_COLOR[row.status]}
size="small"
sx={{ fontSize: '0.7rem' }}
/>
</TableCell>
<TableCell sx={{ fontSize: '0.8125rem' }}>
{row.einsatzleiter_name ?? '—'}
</TableCell>
<TableCell align="right" sx={{ fontSize: '0.8125rem' }}>
{row.personal_count > 0 ? row.personal_count : '—'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<TablePagination <TablePagination
component="div" component="div"
@@ -502,7 +446,6 @@ function Einsaetze() {
`${from}${to} von ${count !== -1 ? count : `mehr als ${to}`}` `${from}${to} von ${count !== -1 ? count : `mehr als ${to}`}`
} }
/> />
</Paper>
{/* Create dialog */} {/* Create dialog */}
<CreateEinsatzDialog <CreateEinsatzDialog

View File

@@ -27,7 +27,6 @@ import {
AccessTime, AccessTime,
DirectionsCar, DirectionsCar,
People, People,
LocationOn,
Description, Description,
PictureAsPdf, PictureAsPdf,
} from '@mui/icons-material'; } from '@mui/icons-material';
@@ -44,6 +43,7 @@ import {
} from '../services/incidents'; } from '../services/incidents';
import { useNotification } from '../contexts/NotificationContext'; import { useNotification } from '../contexts/NotificationContext';
import { usePermissionContext } from '../contexts/PermissionContext'; import { usePermissionContext } from '../contexts/PermissionContext';
import { PageHeader } from '../components/templates';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// COLOUR MAPS // COLOUR MAPS
@@ -280,16 +280,24 @@ function EinsatzDetail() {
return ( return (
<DashboardLayout> <DashboardLayout>
<Container maxWidth="lg"> <Container maxWidth="lg">
{/* Back + Actions */} <PageHeader
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}> title={`Einsatz ${einsatz.einsatz_nr}`}
<Button subtitle={address || undefined}
startIcon={<ArrowBack />} backTo="/einsaetze"
onClick={() => navigate('/einsaetze')} actions={
variant="text" <Stack direction="row" spacing={1} alignItems="center">
> <Chip
Zurück icon={<LocalFireDepartment />}
</Button> label={EINSATZ_ART_LABELS[einsatz.einsatz_art]}
<Stack direction="row" spacing={1}> color={ART_CHIP_COLOR[einsatz.einsatz_art]}
sx={{ fontWeight: 600 }}
/>
<Chip
label={EINSATZ_STATUS_LABELS[einsatz.status]}
color={STATUS_CHIP_COLOR[einsatz.status]}
variant="outlined"
size="small"
/>
<Tooltip title="PDF exportieren (Vorschau)"> <Tooltip title="PDF exportieren (Vorschau)">
<Button <Button
variant="outlined" variant="outlined"
@@ -333,41 +341,14 @@ function EinsatzDetail() {
</> </>
) : null} ) : null}
</Stack> </Stack>
</Box> }
/>
{/* HEADER */}
<Box sx={{ mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flexWrap: 'wrap', mb: 1 }}>
<Chip
icon={<LocalFireDepartment />}
label={EINSATZ_ART_LABELS[einsatz.einsatz_art]}
color={ART_CHIP_COLOR[einsatz.einsatz_art]}
sx={{ fontWeight: 600 }}
/>
<Chip
label={EINSATZ_STATUS_LABELS[einsatz.status]}
color={STATUS_CHIP_COLOR[einsatz.status]}
variant="outlined"
size="small"
/>
{einsatz.einsatz_stichwort && ( {einsatz.einsatz_stichwort && (
<Typography variant="h6" color="text.secondary"> <Typography variant="h6" color="text.secondary" sx={{ mb: 2, mt: -2 }}>
{einsatz.einsatz_stichwort} {einsatz.einsatz_stichwort}
</Typography> </Typography>
)} )}
</Box>
<Typography variant="h4" fontWeight={700}>
Einsatz {einsatz.einsatz_nr}
</Typography>
{address && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.5 }}>
<LocationOn fontSize="small" color="action" />
<Typography variant="body1" color="text.secondary">
{address}
</Typography>
</Box>
)}
</Box>
<Grid container spacing={3}> <Grid container spacing={3}>
{/* LEFT COLUMN: Timeline + Vehicles */} {/* LEFT COLUMN: Timeline + Vehicles */}

View File

@@ -21,14 +21,12 @@ import {
Paper, Paper,
Select, Select,
Stack, Stack,
Tab,
Table, Table,
TableBody, TableBody,
TableCell, TableCell,
TableContainer, TableContainer,
TableHead, TableHead,
TableRow, TableRow,
Tabs,
TextField, TextField,
Tooltip, Tooltip,
Typography, Typography,
@@ -40,7 +38,6 @@ import {
Build, Build,
CheckCircle, CheckCircle,
DeleteOutline, DeleteOutline,
DirectionsCar,
Edit, Edit,
Error as ErrorIcon, Error as ErrorIcon,
History, History,
@@ -55,6 +52,7 @@ import {
} from '@mui/icons-material'; } from '@mui/icons-material';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout'; import DashboardLayout from '../components/dashboard/DashboardLayout';
import { DetailLayout } from '../components/templates';
import ChatAwareFab from '../components/shared/ChatAwareFab'; import ChatAwareFab from '../components/shared/ChatAwareFab';
import { vehiclesApi } from '../services/vehicles'; import { vehiclesApi } from '../services/vehicles';
import GermanDateField from '../components/shared/GermanDateField'; import GermanDateField from '../components/shared/GermanDateField';
@@ -81,20 +79,6 @@ import { usePermissionContext } from '../contexts/PermissionContext';
import { useNotification } from '../contexts/NotificationContext'; import { useNotification } from '../contexts/NotificationContext';
import FahrzeugChecklistTab from '../components/fahrzeuge/FahrzeugChecklistTab'; import FahrzeugChecklistTab from '../components/fahrzeuge/FahrzeugChecklistTab';
// ── Tab Panel ─────────────────────────────────────────────────────────────────
interface TabPanelProps {
children?: React.ReactNode;
index: number;
value: number;
}
const TabPanel: React.FC<TabPanelProps> = ({ children, value, index }) => (
<div role="tabpanel" hidden={value !== index}>
{value === index && <Box sx={{ pt: 3 }}>{children}</Box>}
</div>
);
// ── Status config ───────────────────────────────────────────────────────────── // ── Status config ─────────────────────────────────────────────────────────────
const STATUS_ICONS: Record<FahrzeugStatus, React.ReactElement> = { const STATUS_ICONS: Record<FahrzeugStatus, React.ReactElement> = {
@@ -190,7 +174,7 @@ interface UebersichtTabProps {
canEdit: boolean; canEdit: boolean;
} }
const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated, canChangeStatus, canEdit }) => { const UebersichtTab: React.FC<UebersichtTabProps> = ({ vehicle, onStatusUpdated, canChangeStatus, canEdit: _canEdit }) => {
const [statusDialogOpen, setStatusDialogOpen] = useState(false); const [statusDialogOpen, setStatusDialogOpen] = useState(false);
const [newStatus, setNewStatus] = useState<FahrzeugStatus>(vehicle.status); const [newStatus, setNewStatus] = useState<FahrzeugStatus>(vehicle.status);
const [bemerkung, setBemerkung] = useState(vehicle.status_bemerkung ?? ''); const [bemerkung, setBemerkung] = useState(vehicle.status_bemerkung ?? '');
@@ -889,7 +873,6 @@ function FahrzeugDetail() {
const [vehicle, setVehicle] = useState<FahrzeugDetailType | null>(null); const [vehicle, setVehicle] = useState<FahrzeugDetailType | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState(0);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deleteLoading, setDeleteLoading] = useState(false); const [deleteLoading, setDeleteLoading] = useState(false);
const [vehicleEquipment, setVehicleEquipment] = useState<AusruestungListItem[]>([]); const [vehicleEquipment, setVehicleEquipment] = useState<AusruestungListItem[]>([]);
@@ -958,36 +941,74 @@ function FahrzeugDetail() {
(vehicle.paragraph57a_tage_bis_faelligkeit !== null && vehicle.paragraph57a_tage_bis_faelligkeit < 0) || (vehicle.paragraph57a_tage_bis_faelligkeit !== null && vehicle.paragraph57a_tage_bis_faelligkeit < 0) ||
(vehicle.wartung_tage_bis_faelligkeit !== null && vehicle.wartung_tage_bis_faelligkeit < 0); (vehicle.wartung_tage_bis_faelligkeit !== null && vehicle.wartung_tage_bis_faelligkeit < 0);
const titleText = vehicle.kurzname
? `${vehicle.bezeichnung} ${vehicle.kurzname}`
: vehicle.bezeichnung;
const tabs = [
{
label: 'Übersicht',
content: (
<UebersichtTab
vehicle={vehicle}
onStatusUpdated={fetchVehicle}
canChangeStatus={canChangeStatus}
canEdit={hasPermission('fahrzeuge:edit')}
/>
),
},
{
label: hasOverdue
? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
Wartung <Warning color="error" fontSize="small" />
</Box>
)
: 'Wartung',
content: (
<WartungTab
fahrzeugId={vehicle.id}
wartungslog={vehicle.wartungslog ?? []}
onAdded={fetchVehicle}
canWrite={canManageMaintenance}
/>
),
},
{
label: 'Einsätze',
content: (
<Box sx={{ textAlign: 'center', py: 8 }}>
<LocalFireDepartment sx={{ fontSize: 64, color: 'text.disabled', mb: 2 }} />
<Typography variant="h6" color="text.secondary">
Einsatzhistorie
</Typography>
<Typography variant="body2" color="text.disabled">
Die Einsatzverknüpfung wird in Tier 2 (Einsatzverwaltung) implementiert.
</Typography>
</Box>
),
},
{
label: `Ausrüstung${vehicleEquipment.length > 0 ? ` (${vehicleEquipment.length})` : ''}`,
content: <AusruestungTab equipment={vehicleEquipment} vehicleId={vehicle.id} />,
},
...(hasPermission('checklisten:view')
? [{
label: 'Checklisten',
content: <FahrzeugChecklistTab fahrzeugId={vehicle.id} />,
}]
: []),
];
return ( return (
<DashboardLayout> <DashboardLayout>
<Container maxWidth="lg"> <Container maxWidth="lg">
<Button <DetailLayout
startIcon={<ArrowBack />} title={titleText}
onClick={() => navigate('/fahrzeuge')} backTo="/fahrzeuge"
sx={{ mb: 2 }} tabs={tabs}
size="small" actions={
> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
Fahrzeugübersicht
</Button>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 1 }}>
<DirectionsCar sx={{ fontSize: 36, color: 'text.secondary' }} />
<Box>
<Typography variant="h4" component="h1">
{vehicle.bezeichnung}
{vehicle.kurzname && (
<Typography component="span" variant="h5" color="text.secondary" sx={{ ml: 1 }}>
{vehicle.kurzname}
</Typography>
)}
</Typography>
{vehicle.amtliches_kennzeichen && (
<Typography variant="subtitle1" color="text.secondary">
{vehicle.amtliches_kennzeichen}
</Typography>
)}
</Box>
<Box sx={{ ml: 'auto', display: 'flex', alignItems: 'center', gap: 1 }}>
<Chip <Chip
icon={STATUS_ICONS[vehicle.status]} icon={STATUS_ICONS[vehicle.status]}
label={FahrzeugStatusLabel[vehicle.status]} label={FahrzeugStatusLabel[vehicle.status]}
@@ -1017,71 +1038,8 @@ function FahrzeugDetail() {
</Tooltip> </Tooltip>
)} )}
</Box> </Box>
</Box>
<Box sx={{ borderBottom: 1, borderColor: 'divider', mt: 2 }}>
<Tabs
value={activeTab}
onChange={(_, v) => setActiveTab(v)}
aria-label="Fahrzeug Detailansicht"
variant="scrollable"
scrollButtons="auto"
>
<Tab label="Übersicht" />
<Tab
label={
hasOverdue
? <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
Wartung <Warning color="error" fontSize="small" />
</Box>
: 'Wartung'
} }
/> />
<Tab label="Einsätze" />
<Tab label={`Ausrüstung${vehicleEquipment.length > 0 ? ` (${vehicleEquipment.length})` : ''}`} />
{hasPermission('checklisten:view') && <Tab label="Checklisten" />}
</Tabs>
</Box>
<TabPanel value={activeTab} index={0}>
<UebersichtTab
vehicle={vehicle}
onStatusUpdated={fetchVehicle}
canChangeStatus={canChangeStatus}
canEdit={hasPermission('fahrzeuge:edit')}
/>
</TabPanel>
<TabPanel value={activeTab} index={1}>
<WartungTab
fahrzeugId={vehicle.id}
wartungslog={vehicle.wartungslog ?? []}
onAdded={fetchVehicle}
canWrite={canManageMaintenance}
/>
</TabPanel>
<TabPanel value={activeTab} index={2}>
<Box sx={{ textAlign: 'center', py: 8 }}>
<LocalFireDepartment sx={{ fontSize: 64, color: 'text.disabled', mb: 2 }} />
<Typography variant="h6" color="text.secondary">
Einsatzhistorie
</Typography>
<Typography variant="body2" color="text.disabled">
Die Einsatzverknüpfung wird in Tier 2 (Einsatzverwaltung) implementiert.
</Typography>
</Box>
</TabPanel>
<TabPanel value={activeTab} index={3}>
<AusruestungTab equipment={vehicleEquipment} vehicleId={vehicle.id} />
</TabPanel>
{hasPermission('checklisten:view') && (
<TabPanel value={activeTab} index={4}>
<FahrzeugChecklistTab fahrzeugId={vehicle.id} />
</TabPanel>
)}
{/* Delete confirmation dialog */} {/* Delete confirmation dialog */}
<Dialog open={deleteDialogOpen} onClose={() => !deleteLoading && setDeleteDialogOpen(false)}> <Dialog open={deleteDialogOpen} onClose={() => !deleteLoading && setDeleteDialogOpen(false)}>

View File

@@ -12,9 +12,10 @@ import {
TextField, TextField,
Typography, Typography,
} from '@mui/material'; } from '@mui/material';
import { ArrowBack, Save } from '@mui/icons-material'; import { Save } from '@mui/icons-material';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout'; import DashboardLayout from '../components/dashboard/DashboardLayout';
import { PageHeader } from '../components/templates';
import GermanDateField from '../components/shared/GermanDateField'; import GermanDateField from '../components/shared/GermanDateField';
import { vehiclesApi } from '../services/vehicles'; import { vehiclesApi } from '../services/vehicles';
import { fahrzeugTypenApi } from '../services/fahrzeugTypen'; import { fahrzeugTypenApi } from '../services/fahrzeugTypen';
@@ -232,7 +233,7 @@ function FahrzeugForm() {
<DashboardLayout> <DashboardLayout>
<Container maxWidth="md"> <Container maxWidth="md">
<Alert severity="error">{error}</Alert> <Alert severity="error">{error}</Alert>
<Button startIcon={<ArrowBack />} onClick={() => navigate('/fahrzeuge')} sx={{ mt: 2 }}> <Button onClick={() => navigate('/fahrzeuge')} sx={{ mt: 2 }}>
Zurück Zurück
</Button> </Button>
</Container> </Container>
@@ -243,18 +244,15 @@ function FahrzeugForm() {
return ( return (
<DashboardLayout> <DashboardLayout>
<Container maxWidth="md"> <Container maxWidth="md">
<Button <PageHeader
startIcon={<ArrowBack />} title={isEditMode ? 'Fahrzeug bearbeiten' : 'Neues Fahrzeug erfassen'}
onClick={() => (isEditMode && id ? navigate(`/fahrzeuge/${id}`) : navigate('/fahrzeuge'))} breadcrumbs={[
sx={{ mb: 2 }} { label: 'Fahrzeuge', href: '/fahrzeuge' },
size="small" ...(isEditMode && id ? [{ label: 'Detail', href: `/fahrzeuge/${id}` }] : []),
> { label: isEditMode ? 'Bearbeiten' : 'Neu' },
{isEditMode ? 'Zurück zur Detailansicht' : 'Fahrzeugübersicht'} ]}
</Button> backTo={isEditMode && id ? `/fahrzeuge/${id}` : '/fahrzeuge'}
/>
<Typography variant="h4" gutterBottom>
{isEditMode ? 'Fahrzeug bearbeiten' : 'Neues Fahrzeug erfassen'}
</Typography>
{saveError && <Alert severity="error" sx={{ mb: 2 }}>{saveError}</Alert>} {saveError && <Alert severity="error" sx={{ mb: 2 }}>{saveError}</Alert>}

View File

@@ -10,10 +10,6 @@ import {
Chip, Chip,
CircularProgress, CircularProgress,
Container, Container,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Grid, Grid,
IconButton, IconButton,
InputAdornment, InputAdornment,
@@ -63,6 +59,7 @@ import type { FahrzeugTyp } from '../types/checklist.types';
import { usePermissions } from '../hooks/usePermissions'; import { usePermissions } from '../hooks/usePermissions';
import { usePermissionContext } from '../contexts/PermissionContext'; import { usePermissionContext } from '../contexts/PermissionContext';
import { useNotification } from '../contexts/NotificationContext'; import { useNotification } from '../contexts/NotificationContext';
import { FormDialog } from '../components/templates';
// ── Status chip config ──────────────────────────────────────────────────────── // ── Status chip config ────────────────────────────────────────────────────────
@@ -447,11 +444,13 @@ function FahrzeugTypenSettings() {
</> </>
)} )}
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth> <FormDialog
<DialogTitle> open={dialogOpen}
{editing ? 'Fahrzeugtyp bearbeiten' : 'Neuer Fahrzeugtyp'} onClose={() => setDialogOpen(false)}
</DialogTitle> onSubmit={handleSubmit}
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}> title={editing ? 'Fahrzeugtyp bearbeiten' : 'Neuer Fahrzeugtyp'}
isSubmitting={isSaving}
>
<TextField <TextField
label="Name *" label="Name *"
fullWidth fullWidth
@@ -471,18 +470,7 @@ function FahrzeugTypenSettings() {
onChange={(e) => setForm((f) => ({ ...f, icon: e.target.value }))} onChange={(e) => setForm((f) => ({ ...f, icon: e.target.value }))}
placeholder="z.B. fire_truck" placeholder="z.B. fire_truck"
/> />
</DialogContent> </FormDialog>
<DialogActions>
<Button onClick={() => setDialogOpen(false)}>Abbrechen</Button>
<Button
variant="contained"
onClick={handleSubmit}
disabled={isSaving || !form.name.trim()}
>
{isSaving ? <CircularProgress size={20} /> : 'Speichern'}
</Button>
</DialogActions>
</Dialog>
</Box> </Box>
); );
} }

View File

@@ -1,7 +1,7 @@
import { useState, useMemo } from 'react'; import { useState, useMemo } from 'react';
import { import {
Box, Typography, Paper, Chip, IconButton, Button, Dialog, DialogTitle, Box, Typography, Paper, Chip, IconButton, Button,
DialogContent, DialogActions, TextField, MenuItem, Select, FormControl, TextField, MenuItem, Select, FormControl,
InputLabel, Divider, CircularProgress, Autocomplete, Grid, Card, CardContent, InputLabel, Divider, CircularProgress, Autocomplete, Grid, Card, CardContent,
List, ListItem, ListItemIcon, ListItemText, ListItemSecondaryAction, List, ListItem, ListItemIcon, ListItemText, ListItemSecondaryAction,
} from '@mui/material'; } from '@mui/material';
@@ -20,6 +20,7 @@ import { usePermissionContext } from '../contexts/PermissionContext';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import { issuesApi } from '../services/issues'; import { issuesApi } from '../services/issues';
import type { Issue, IssueComment, UpdateIssuePayload, AssignableMember, IssueStatusDef, IssuePriorityDef, IssueHistorie, IssueDatei } from '../types/issue.types'; import type { Issue, IssueComment, UpdateIssuePayload, AssignableMember, IssueStatusDef, IssuePriorityDef, IssueHistorie, IssueDatei } from '../types/issue.types';
import { ConfirmDialog, FormDialog, PageHeader } from '../components/templates';
// ── Helpers (copied from Issues.tsx) ── // ── Helpers (copied from Issues.tsx) ──
@@ -260,21 +261,16 @@ export default function IssueDetail() {
return ( return (
<DashboardLayout> <DashboardLayout>
<Box sx={{ p: 3 }}> <Box sx={{ p: 3 }}>
{/* Header */} <PageHeader
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}> title={`${formatIssueId(issue)}${issue.titel}`}
<IconButton onClick={() => navigate('/issues')}> backTo="/issues"
<ArrowBack /> actions={
</IconButton>
<Box sx={{ flex: 1 }}>
<Typography variant="h5">
{formatIssueId(issue)} {issue.titel}
</Typography>
</Box>
<Chip <Chip
label={getStatusLabel(statuses, issue.status)} label={getStatusLabel(statuses, issue.status)}
color={getStatusColor(statuses, issue.status)} color={getStatusColor(statuses, issue.status)}
/> />
</Box> }
/>
{/* Info cards */} {/* Info cards */}
<Grid container spacing={2} sx={{ mb: 3 }}> <Grid container spacing={2} sx={{ mb: 3 }}>
@@ -559,9 +555,15 @@ export default function IssueDetail() {
</Box> </Box>
{/* Reopen Dialog */} {/* Reopen Dialog */}
<Dialog open={reopenOpen} onClose={() => setReopenOpen(false)} maxWidth="sm" fullWidth> <FormDialog
<DialogTitle>Issue wiedereröffnen</DialogTitle> open={reopenOpen}
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: '20px !important' }}> onClose={() => setReopenOpen(false)}
onSubmit={handleReopen}
title="Issue wiedereröffnen"
submitLabel="Wiedereröffnen"
isSubmitting={updateMut.isPending}
maxWidth="sm"
>
<TextField <TextField
label="Kommentar (Pflicht)" label="Kommentar (Pflicht)"
required required
@@ -572,37 +574,19 @@ export default function IssueDetail() {
onChange={(e) => setReopenComment(e.target.value)} onChange={(e) => setReopenComment(e.target.value)}
autoFocus autoFocus
/> />
</DialogContent> </FormDialog>
<DialogActions>
<Button onClick={() => setReopenOpen(false)}>Abbrechen</Button>
<Button
variant="contained"
disabled={!reopenComment.trim() || updateMut.isPending}
onClick={handleReopen}
>
Wiedereröffnen
</Button>
</DialogActions>
</Dialog>
{/* Delete Confirmation Dialog */} {/* Delete Confirmation Dialog */}
<Dialog open={deleteOpen} onClose={() => setDeleteOpen(false)} maxWidth="xs" fullWidth> <ConfirmDialog
<DialogTitle>Issue löschen</DialogTitle> open={deleteOpen}
<DialogContent> onClose={() => setDeleteOpen(false)}
<Typography>Soll dieses Issue wirklich gelöscht werden?</Typography> onConfirm={() => deleteMut.mutate()}
</DialogContent> title="Issue löschen"
<DialogActions> message="Soll dieses Issue wirklich gelöscht werden?"
<Button onClick={() => setDeleteOpen(false)}>Abbrechen</Button> confirmLabel="Löschen"
<Button confirmColor="error"
variant="contained" isLoading={deleteMut.isPending}
color="error" />
disabled={deleteMut.isPending}
onClick={() => deleteMut.mutate()}
>
Löschen
</Button>
</DialogActions>
</Dialog>
</DashboardLayout> </DashboardLayout>
); );
} }

View File

@@ -1,12 +1,13 @@
import { useState } from 'react'; import { useState } from 'react';
import { import {
Box, Typography, Paper, Button, TextField, MenuItem, Select, FormControl, Button, TextField, MenuItem, Select, FormControl,
InputLabel, IconButton, Grid, Collapse, InputLabel, Grid, Collapse,
} from '@mui/material'; } from '@mui/material';
import { ArrowBack, Add as AddIcon } from '@mui/icons-material'; import { Add as AddIcon } from '@mui/icons-material';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout'; import DashboardLayout from '../components/dashboard/DashboardLayout';
import { PageHeader, FormLayout } from '../components/templates';
import { useNotification } from '../contexts/NotificationContext'; import { useNotification } from '../contexts/NotificationContext';
import { issuesApi } from '../services/issues'; import { issuesApi } from '../services/issues';
import type { CreateIssuePayload } from '../types/issue.types'; import type { CreateIssuePayload } from '../types/issue.types';
@@ -52,16 +53,27 @@ export default function IssueNeu() {
return ( return (
<DashboardLayout> <DashboardLayout>
{/* Header */} <PageHeader
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}> title="Neues Issue"
<IconButton onClick={() => navigate('/issues')}> breadcrumbs={[
<ArrowBack /> { label: 'Issues', href: '/issues' },
</IconButton> { label: 'Neues Issue' },
<Typography variant="h5">Neues Issue</Typography> ]}
</Box> backTo="/issues"
/>
<Paper sx={{ p: 3 }}> <FormLayout
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}> actions={<>
<Button onClick={() => navigate('/issues')}>Abbrechen</Button>
<Button
variant="contained"
disabled={!form.titel.trim() || createMut.isPending}
onClick={handleSubmit}
>
Erstellen
</Button>
</>}
>
<TextField <TextField
label="Titel" label="Titel"
required required
@@ -132,19 +144,7 @@ export default function IssueNeu() {
/> />
</Grid> </Grid>
</Grid> </Grid>
</FormLayout>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1, mt: 1 }}>
<Button onClick={() => navigate('/issues')}>Abbrechen</Button>
<Button
variant="contained"
disabled={!form.titel.trim() || createMut.isPending}
onClick={handleSubmit}
>
Erstellen
</Button>
</Box>
</Box>
</Paper>
</DashboardLayout> </DashboardLayout>
); );
} }

View File

@@ -1,11 +1,11 @@
import { useState, useMemo } from 'react'; import { useState, useMemo } from 'react';
import { import {
Box, Tab, Tabs, Typography, Table, TableBody, TableCell, TableContainer, Box, Tab, Tabs, Typography, Table, TableBody, TableCell, TableContainer,
TableHead, TableRow, Paper, Chip, IconButton, Button, Dialog, DialogTitle, TableHead, TableRow, Paper, Chip, IconButton, Button, TextField, MenuItem, Select, FormControl,
DialogContent, DialogActions, TextField, MenuItem, Select, FormControl,
InputLabel, CircularProgress, FormControlLabel, Switch, InputLabel, CircularProgress, FormControlLabel, Switch,
Autocomplete, ToggleButtonGroup, ToggleButton, Autocomplete, ToggleButtonGroup, ToggleButton,
} from '@mui/material'; } from '@mui/material';
// Note: Table/TableBody/etc still needed for IssueSettings tables
import { import {
Add as AddIcon, Delete as DeleteIcon, Add as AddIcon, Delete as DeleteIcon,
BugReport, FiberNew, HelpOutline, BugReport, FiberNew, HelpOutline,
@@ -17,6 +17,8 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useSearchParams, useNavigate } from 'react-router-dom'; import { useSearchParams, useNavigate } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout'; import DashboardLayout from '../components/dashboard/DashboardLayout';
import ChatAwareFab from '../components/shared/ChatAwareFab'; import ChatAwareFab from '../components/shared/ChatAwareFab';
import { DataTable, FormDialog } from '../components/templates';
import type { Column } from '../components/templates';
import { useNotification } from '../contexts/NotificationContext'; import { useNotification } from '../contexts/NotificationContext';
import { usePermissionContext } from '../contexts/PermissionContext'; import { usePermissionContext } from '../contexts/PermissionContext';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
@@ -91,68 +93,45 @@ function IssueTable({
}) { }) {
const navigate = useNavigate(); const navigate = useNavigate();
if (issues.length === 0) { const columns: Column<Issue>[] = [
return ( { key: 'id', label: 'ID', width: 80, render: (row) => formatIssueId(row) },
<Typography color="text.secondary" sx={{ py: 4, textAlign: 'center' }}> {
Keine Issues vorhanden key: 'titel', label: 'Titel', render: (row) => (
</Typography> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
); {getTypIcon(row.typ_icon, row.typ_farbe)}
} <Typography variant="body2">{row.titel}</Typography>
</Box>
),
},
{ key: 'typ_name', label: 'Typ', render: (row) => <Chip label={row.typ_name} size="small" variant="outlined" /> },
{
key: 'prioritaet', label: 'Priorität', render: (row) => (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<CircleIcon sx={{ fontSize: 10, color: getPrioColor(priorities, row.prioritaet) }} />
<Typography variant="body2">{getPrioLabel(priorities, row.prioritaet)}</Typography>
</Box>
),
},
{
key: 'status', label: 'Status', render: (row) => (
<Chip label={getStatusLabel(statuses, row.status)} size="small" color={getStatusColor(statuses, row.status)} />
),
},
{ key: 'erstellt_von_name', label: 'Erstellt von', render: (row) => row.erstellt_von_name || '-' },
{ key: 'zugewiesen_an_name', label: 'Zugewiesen an', render: (row) => row.zugewiesen_an_name || '-' },
{ key: 'created_at', label: 'Erstellt am', render: (row) => formatDate(row.created_at) },
];
return ( return (
<TableContainer component={Paper} variant="outlined"> <DataTable
<Table size="small"> columns={columns}
<TableHead> data={issues}
<TableRow> rowKey={(row) => row.id}
<TableCell>ID</TableCell> onRowClick={(row) => navigate(`/issues/${row.id}`)}
<TableCell>Titel</TableCell> emptyMessage="Keine Issues vorhanden"
<TableCell>Typ</TableCell> searchEnabled={false}
<TableCell>Priorität</TableCell> paginationEnabled={false}
<TableCell>Status</TableCell>
<TableCell>Erstellt von</TableCell>
<TableCell>Zugewiesen an</TableCell>
<TableCell>Erstellt am</TableCell>
</TableRow>
</TableHead>
<TableBody>
{issues.map((issue) => (
<TableRow
key={issue.id}
hover
sx={{ cursor: 'pointer' }}
onClick={() => navigate(`/issues/${issue.id}`)}
>
<TableCell sx={{ width: 80 }}>{formatIssueId(issue)}</TableCell>
<TableCell>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{getTypIcon(issue.typ_icon, issue.typ_farbe)}
<Typography variant="body2">{issue.titel}</Typography>
</Box>
</TableCell>
<TableCell>
<Chip label={issue.typ_name} size="small" variant="outlined" />
</TableCell>
<TableCell>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<CircleIcon sx={{ fontSize: 10, color: getPrioColor(priorities, issue.prioritaet) }} />
<Typography variant="body2">{getPrioLabel(priorities, issue.prioritaet)}</Typography>
</Box>
</TableCell>
<TableCell>
<Chip
label={getStatusLabel(statuses, issue.status)}
size="small"
color={getStatusColor(statuses, issue.status)}
/> />
</TableCell>
<TableCell>{issue.erstellt_von_name || '-'}</TableCell>
<TableCell>{issue.zugewiesen_an_name || '-'}</TableCell>
<TableCell>{formatDate(issue.created_at)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
); );
} }
@@ -509,9 +488,14 @@ function IssueSettings() {
</Box> </Box>
{/* ──── Create Status Dialog ──── */} {/* ──── Create Status Dialog ──── */}
<Dialog open={statusCreateOpen} onClose={() => setStatusCreateOpen(false)} maxWidth="sm" fullWidth> <FormDialog
<DialogTitle>Neuer Status</DialogTitle> open={statusCreateOpen}
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: '20px !important' }}> onClose={() => setStatusCreateOpen(false)}
onSubmit={() => createStatusMut.mutate(statusCreateData)}
title="Neuer Status"
submitLabel="Erstellen"
isSubmitting={createStatusMut.isPending}
>
<TextField label="Schlüssel" required fullWidth value={statusCreateData.schluessel || ''} onChange={(e) => setStatusCreateData({ ...statusCreateData, schluessel: e.target.value })} helperText="Maschinenwert, z.B. 'in_pruefung' (nicht änderbar)" autoFocus /> <TextField label="Schlüssel" required fullWidth value={statusCreateData.schluessel || ''} onChange={(e) => setStatusCreateData({ ...statusCreateData, schluessel: e.target.value })} helperText="Maschinenwert, z.B. 'in_pruefung' (nicht änderbar)" autoFocus />
<TextField label="Bezeichnung" required fullWidth value={statusCreateData.bezeichnung || ''} onChange={(e) => setStatusCreateData({ ...statusCreateData, bezeichnung: e.target.value })} /> <TextField label="Bezeichnung" required fullWidth value={statusCreateData.bezeichnung || ''} onChange={(e) => setStatusCreateData({ ...statusCreateData, bezeichnung: e.target.value })} />
<Box><Typography variant="body2" sx={{ mb: 0.5 }}>Farbe</Typography><ColorSwatch colors={MUI_CHIP_COLORS} value={statusCreateData.farbe || 'default'} onChange={(v) => setStatusCreateData({ ...statusCreateData, farbe: v })} /></Box> <Box><Typography variant="body2" sx={{ mb: 0.5 }}>Farbe</Typography><ColorSwatch colors={MUI_CHIP_COLORS} value={statusCreateData.farbe || 'default'} onChange={(v) => setStatusCreateData({ ...statusCreateData, farbe: v })} /></Box>
@@ -519,35 +503,39 @@ function IssueSettings() {
<FormControlLabel control={<Switch checked={statusCreateData.ist_abschluss ?? false} onChange={(e) => setStatusCreateData({ ...statusCreateData, ist_abschluss: e.target.checked })} />} label="Abschluss-Status (erledigte Issues)" /> <FormControlLabel control={<Switch checked={statusCreateData.ist_abschluss ?? false} onChange={(e) => setStatusCreateData({ ...statusCreateData, ist_abschluss: e.target.checked })} />} label="Abschluss-Status (erledigte Issues)" />
<FormControlLabel control={<Switch checked={statusCreateData.ist_initial ?? false} onChange={(e) => setStatusCreateData({ ...statusCreateData, ist_initial: e.target.checked })} />} label="Initial-Status (Ziel beim Wiedereröffnen)" /> <FormControlLabel control={<Switch checked={statusCreateData.ist_initial ?? false} onChange={(e) => setStatusCreateData({ ...statusCreateData, ist_initial: e.target.checked })} />} label="Initial-Status (Ziel beim Wiedereröffnen)" />
<FormControlLabel control={<Switch checked={statusCreateData.benoetigt_typ_freigabe ?? false} onChange={(e) => setStatusCreateData({ ...statusCreateData, benoetigt_typ_freigabe: e.target.checked })} />} label="Nur bei Typ-Freigabe erlaubt" /> <FormControlLabel control={<Switch checked={statusCreateData.benoetigt_typ_freigabe ?? false} onChange={(e) => setStatusCreateData({ ...statusCreateData, benoetigt_typ_freigabe: e.target.checked })} />} label="Nur bei Typ-Freigabe erlaubt" />
</DialogContent> </FormDialog>
<DialogActions><Button onClick={() => setStatusCreateOpen(false)}>Abbrechen</Button><Button variant="contained" onClick={() => createStatusMut.mutate(statusCreateData)} disabled={!statusCreateData.schluessel?.trim() || !statusCreateData.bezeichnung?.trim() || createStatusMut.isPending}>Erstellen</Button></DialogActions>
</Dialog>
{/* ──── Create Priority Dialog ──── */} {/* ──── Create Priority Dialog ──── */}
<Dialog open={prioCreateOpen} onClose={() => setPrioCreateOpen(false)} maxWidth="sm" fullWidth> <FormDialog
<DialogTitle>Neue Priorität</DialogTitle> open={prioCreateOpen}
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: '20px !important' }}> onClose={() => setPrioCreateOpen(false)}
onSubmit={() => createPrioMut.mutate(prioCreateData)}
title="Neue Priorität"
submitLabel="Erstellen"
isSubmitting={createPrioMut.isPending}
>
<TextField label="Schlüssel" required fullWidth value={prioCreateData.schluessel || ''} onChange={(e) => setPrioCreateData({ ...prioCreateData, schluessel: e.target.value })} helperText="Maschinenwert, z.B. 'kritisch'" autoFocus /> <TextField label="Schlüssel" required fullWidth value={prioCreateData.schluessel || ''} onChange={(e) => setPrioCreateData({ ...prioCreateData, schluessel: e.target.value })} helperText="Maschinenwert, z.B. 'kritisch'" autoFocus />
<TextField label="Bezeichnung" required fullWidth value={prioCreateData.bezeichnung || ''} onChange={(e) => setPrioCreateData({ ...prioCreateData, bezeichnung: e.target.value })} /> <TextField label="Bezeichnung" required fullWidth value={prioCreateData.bezeichnung || ''} onChange={(e) => setPrioCreateData({ ...prioCreateData, bezeichnung: e.target.value })} />
<Box><Typography variant="body2" sx={{ mb: 0.5 }}>Farbe</Typography><HexColorInput value={prioCreateData.farbe || '#9e9e9e'} onChange={(v) => setPrioCreateData({ ...prioCreateData, farbe: v })} /></Box> <Box><Typography variant="body2" sx={{ mb: 0.5 }}>Farbe</Typography><HexColorInput value={prioCreateData.farbe || '#9e9e9e'} onChange={(v) => setPrioCreateData({ ...prioCreateData, farbe: v })} /></Box>
<TextField label="Sortierung" type="number" value={prioCreateData.sort_order ?? 0} onChange={(e) => setPrioCreateData({ ...prioCreateData, sort_order: parseInt(e.target.value) || 0 })} /> <TextField label="Sortierung" type="number" value={prioCreateData.sort_order ?? 0} onChange={(e) => setPrioCreateData({ ...prioCreateData, sort_order: parseInt(e.target.value) || 0 })} />
</DialogContent> </FormDialog>
<DialogActions><Button onClick={() => setPrioCreateOpen(false)}>Abbrechen</Button><Button variant="contained" onClick={() => createPrioMut.mutate(prioCreateData)} disabled={!prioCreateData.schluessel?.trim() || !prioCreateData.bezeichnung?.trim() || createPrioMut.isPending}>Erstellen</Button></DialogActions>
</Dialog>
{/* ──── Create Kategorie Dialog ──── */} {/* ──── Create Kategorie Dialog ──── */}
<Dialog open={typeCreateOpen} onClose={() => setTypeCreateOpen(false)} maxWidth="sm" fullWidth> <FormDialog
<DialogTitle>Neue Kategorie</DialogTitle> open={typeCreateOpen}
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: '20px !important' }}> onClose={() => setTypeCreateOpen(false)}
onSubmit={() => createTypeMut.mutate(typeCreateData)}
title="Neue Kategorie"
submitLabel="Erstellen"
isSubmitting={createTypeMut.isPending}
>
<TextField label="Name" required fullWidth value={typeCreateData.name || ''} onChange={(e) => setTypeCreateData({ ...typeCreateData, name: e.target.value })} autoFocus /> <TextField label="Name" required fullWidth value={typeCreateData.name || ''} onChange={(e) => setTypeCreateData({ ...typeCreateData, name: e.target.value })} autoFocus />
<FormControl fullWidth><InputLabel>Übergeordnete Kategorie</InputLabel><Select value={typeCreateData.parent_id ?? ''} label="Übergeordnete Kategorie" onChange={(e) => setTypeCreateData({ ...typeCreateData, parent_id: e.target.value ? Number(e.target.value) : null })}><MenuItem value="">Keine</MenuItem>{types.filter(t => !t.parent_id).map(t => <MenuItem key={t.id} value={t.id}>{t.name}</MenuItem>)}</Select></FormControl> <FormControl fullWidth><InputLabel>Übergeordnete Kategorie</InputLabel><Select value={typeCreateData.parent_id ?? ''} label="Übergeordnete Kategorie" onChange={(e) => setTypeCreateData({ ...typeCreateData, parent_id: e.target.value ? Number(e.target.value) : null })}><MenuItem value="">Keine</MenuItem>{types.filter(t => !t.parent_id).map(t => <MenuItem key={t.id} value={t.id}>{t.name}</MenuItem>)}</Select></FormControl>
<FormControl fullWidth><InputLabel>Icon</InputLabel><Select value={typeCreateData.icon || 'HelpOutline'} label="Icon" onChange={(e) => setTypeCreateData({ ...typeCreateData, icon: e.target.value })}><MenuItem value="BugReport">BugReport</MenuItem><MenuItem value="FiberNew">FiberNew</MenuItem><MenuItem value="HelpOutline">HelpOutline</MenuItem></Select></FormControl> <FormControl fullWidth><InputLabel>Icon</InputLabel><Select value={typeCreateData.icon || 'HelpOutline'} label="Icon" onChange={(e) => setTypeCreateData({ ...typeCreateData, icon: e.target.value })}><MenuItem value="BugReport">BugReport</MenuItem><MenuItem value="FiberNew">FiberNew</MenuItem><MenuItem value="HelpOutline">HelpOutline</MenuItem></Select></FormControl>
<Box><Typography variant="body2" sx={{ mb: 0.5 }}>Farbe</Typography><ColorSwatch colors={ICON_COLORS} value={typeCreateData.farbe || 'action'} onChange={(v) => setTypeCreateData({ ...typeCreateData, farbe: v })} /></Box> <Box><Typography variant="body2" sx={{ mb: 0.5 }}>Farbe</Typography><ColorSwatch colors={ICON_COLORS} value={typeCreateData.farbe || 'action'} onChange={(v) => setTypeCreateData({ ...typeCreateData, farbe: v })} /></Box>
<FormControlLabel control={<Switch checked={typeCreateData.erlaubt_abgelehnt ?? true} onChange={(e) => setTypeCreateData({ ...typeCreateData, erlaubt_abgelehnt: e.target.checked })} />} label="Abgelehnt erlaubt" /> <FormControlLabel control={<Switch checked={typeCreateData.erlaubt_abgelehnt ?? true} onChange={(e) => setTypeCreateData({ ...typeCreateData, erlaubt_abgelehnt: e.target.checked })} />} label="Abgelehnt erlaubt" />
<TextField label="Sortierung" type="number" value={typeCreateData.sort_order ?? 0} onChange={(e) => setTypeCreateData({ ...typeCreateData, sort_order: parseInt(e.target.value) || 0 })} /> <TextField label="Sortierung" type="number" value={typeCreateData.sort_order ?? 0} onChange={(e) => setTypeCreateData({ ...typeCreateData, sort_order: parseInt(e.target.value) || 0 })} />
</DialogContent> </FormDialog>
<DialogActions><Button onClick={() => setTypeCreateOpen(false)}>Abbrechen</Button><Button variant="contained" disabled={!typeCreateData.name?.trim() || createTypeMut.isPending} onClick={() => createTypeMut.mutate(typeCreateData)}>Erstellen</Button></DialogActions>
</Dialog>
</Box> </Box>
); );

View File

@@ -5,17 +5,9 @@ import {
Paper, Paper,
Button, Button,
TextField, TextField,
IconButton,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Grid,
Card,
CardContent,
Skeleton, Skeleton,
} from '@mui/material'; } from '@mui/material';
import { ArrowBack, Edit as EditIcon, Delete as DeleteIcon, Save as SaveIcon, Close as CloseIcon } from '@mui/icons-material'; import { Edit as EditIcon, Delete as DeleteIcon, Save as SaveIcon, Close as CloseIcon } from '@mui/icons-material';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout'; import DashboardLayout from '../components/dashboard/DashboardLayout';
@@ -23,6 +15,7 @@ import { useNotification } from '../contexts/NotificationContext';
import { usePermissionContext } from '../contexts/PermissionContext'; import { usePermissionContext } from '../contexts/PermissionContext';
import { bestellungApi } from '../services/bestellung'; import { bestellungApi } from '../services/bestellung';
import type { LieferantFormData } from '../types/bestellung.types'; import type { LieferantFormData } from '../types/bestellung.types';
import { ConfirmDialog, PageHeader, InfoGrid } from '../components/templates';
const emptyForm: LieferantFormData = { name: '', kontakt_name: '', email: '', telefon: '', adresse: '', website: '', notizen: '' }; const emptyForm: LieferantFormData = { name: '', kontakt_name: '', email: '', telefon: '', adresse: '', website: '', notizen: '' };
@@ -151,11 +144,9 @@ export default function LieferantDetail() {
} }
return ( return (
<DashboardLayout> <DashboardLayout>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}> <PageHeader title="" backTo="/bestellungen?tab=1" />
<IconButton onClick={() => navigate('/bestellungen?tab=1')}><ArrowBack /></IconButton>
<Skeleton width={300} height={40} /> <Skeleton width={300} height={40} />
</Box> <Paper sx={{ p: 3, mt: 2 }}>
<Paper sx={{ p: 3 }}>
<Skeleton height={40} /> <Skeleton height={40} />
<Skeleton height={40} /> <Skeleton height={40} />
<Skeleton height={40} /> <Skeleton height={40} />
@@ -169,13 +160,16 @@ export default function LieferantDetail() {
return ( return (
<DashboardLayout> <DashboardLayout>
{/* ── Header ── */} {/* ── Header ── */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}> <PageHeader
<IconButton onClick={() => navigate('/bestellungen?tab=1')}> title={isNew ? 'Neuer Lieferant' : vendor!.name}
<ArrowBack /> breadcrumbs={[
</IconButton> { label: 'Bestellungen', href: '/bestellungen' },
<Typography variant="h4" sx={{ flexGrow: 1 }}> { label: 'Lieferanten', href: '/bestellungen?tab=1' },
{isNew ? 'Neuer Lieferant' : vendor!.name} { label: isNew ? 'Neu' : vendor!.name },
</Typography> ]}
backTo="/bestellungen?tab=1"
actions={
<>
{!isNew && canManage && !editMode && ( {!isNew && canManage && !editMode && (
<> <>
<Button startIcon={<EditIcon />} onClick={() => setEditMode(true)}> <Button startIcon={<EditIcon />} onClick={() => setEditMode(true)}>
@@ -201,7 +195,9 @@ export default function LieferantDetail() {
</Button> </Button>
</> </>
)} )}
</Box> </>
}
/>
{/* ── Content ── */} {/* ── Content ── */}
{editMode ? ( {editMode ? (
@@ -249,73 +245,31 @@ export default function LieferantDetail() {
</Box> </Box>
</Paper> </Paper>
) : ( ) : (
<Grid container spacing={2}> <InfoGrid
<Grid item xs={12} sm={6} md={4}> columns={2}
<Card variant="outlined"><CardContent> fields={[
<Typography variant="caption" color="text.secondary">Name</Typography> { label: 'Name', value: vendor!.name },
<Typography>{vendor!.name}</Typography> { label: 'Kontakt', value: vendor!.kontakt_name || '' },
</CardContent></Card> { label: 'E-Mail', value: vendor!.email ? <a href={`mailto:${vendor!.email}`}>{vendor!.email}</a> : '' },
</Grid> { label: 'Telefon', value: vendor!.telefon || '' },
<Grid item xs={12} sm={6} md={4}> { label: 'Website', value: vendor!.website ? <a href={ensureUrl(vendor!.website)} target="_blank" rel="noopener noreferrer">{vendor!.website}</a> : '' },
<Card variant="outlined"><CardContent> { label: 'Adresse', value: vendor!.adresse || '' },
<Typography variant="caption" color="text.secondary">Kontakt</Typography> ...(vendor!.notizen ? [{ label: 'Notizen', value: <Typography sx={{ whiteSpace: 'pre-wrap' }}>{vendor!.notizen}</Typography>, fullWidth: true }] : []),
<Typography>{vendor!.kontakt_name || ''}</Typography> ]}
</CardContent></Card> />
</Grid>
<Grid item xs={12} sm={6} md={4}>
<Card variant="outlined"><CardContent>
<Typography variant="caption" color="text.secondary">E-Mail</Typography>
<Typography>
{vendor!.email ? <a href={`mailto:${vendor!.email}`}>{vendor!.email}</a> : ''}
</Typography>
</CardContent></Card>
</Grid>
<Grid item xs={12} sm={6} md={4}>
<Card variant="outlined"><CardContent>
<Typography variant="caption" color="text.secondary">Telefon</Typography>
<Typography>{vendor!.telefon || ''}</Typography>
</CardContent></Card>
</Grid>
<Grid item xs={12} sm={6} md={4}>
<Card variant="outlined"><CardContent>
<Typography variant="caption" color="text.secondary">Website</Typography>
<Typography>
{vendor!.website ? <a href={ensureUrl(vendor!.website)} target="_blank" rel="noopener noreferrer">{vendor!.website}</a> : ''}
</Typography>
</CardContent></Card>
</Grid>
<Grid item xs={12} sm={6} md={4}>
<Card variant="outlined"><CardContent>
<Typography variant="caption" color="text.secondary">Adresse</Typography>
<Typography>{vendor!.adresse || ''}</Typography>
</CardContent></Card>
</Grid>
{vendor!.notizen && (
<Grid item xs={12}>
<Card variant="outlined"><CardContent>
<Typography variant="caption" color="text.secondary">Notizen</Typography>
<Typography sx={{ whiteSpace: 'pre-wrap' }}>{vendor!.notizen}</Typography>
</CardContent></Card>
</Grid>
)}
</Grid>
)} )}
{/* ── Delete Dialog ── */} {/* ── Delete Dialog ── */}
<Dialog open={deleteDialogOpen} onClose={() => setDeleteDialogOpen(false)}> <ConfirmDialog
<DialogTitle>Lieferant löschen</DialogTitle> open={deleteDialogOpen}
<DialogContent> onClose={() => setDeleteDialogOpen(false)}
<Typography> onConfirm={() => deleteVendor.mutate()}
Soll der Lieferant <strong>{vendor?.name}</strong> wirklich gelöscht werden? title="Lieferant löschen"
</Typography> message={<Typography>Soll der Lieferant <strong>{vendor?.name}</strong> wirklich gelöscht werden?</Typography>}
</DialogContent> confirmLabel="Löschen"
<DialogActions> confirmColor="error"
<Button onClick={() => setDeleteDialogOpen(false)}>Abbrechen</Button> isLoading={deleteVendor.isPending}
<Button color="error" variant="contained" onClick={() => deleteVendor.mutate()} disabled={deleteVendor.isPending}> />
Löschen
</Button>
</DialogActions>
</Dialog>
</DashboardLayout> </DashboardLayout>
); );
} }

View File

@@ -61,6 +61,7 @@ import {
UpdateMemberProfileData, UpdateMemberProfileData,
} from '../types/member.types'; } from '../types/member.types';
import type { Befoerderung, Untersuchung, Fahrgenehmigung, Ausbildung } from '../types/member.types'; import type { Befoerderung, Untersuchung, Fahrgenehmigung, Ausbildung } from '../types/member.types';
import { StatusChip, TabPanel, PageHeader } from '../components/templates';
import type { AtemschutzUebersicht } from '../types/atemschutz.types'; import type { AtemschutzUebersicht } from '../types/atemschutz.types';
import { UntersuchungErgebnisLabel } from '../types/atemschutz.types'; import { UntersuchungErgebnisLabel } from '../types/atemschutz.types';
@@ -77,23 +78,6 @@ function useCurrentUserId(): string | undefined {
return (user as any)?.id; return (user as any)?.id;
} }
// ----------------------------------------------------------------
// Tab panel helper
// ----------------------------------------------------------------
interface TabPanelProps {
children?: React.ReactNode;
value: number;
index: number;
}
function TabPanel({ children, value, index }: TabPanelProps) {
return (
<div role="tabpanel" hidden={value !== index} aria-labelledby={`tab-${index}`}>
{value === index && <Box sx={{ pt: 3 }}>{children}</Box>}
</div>
);
}
// ---------------------------------------------------------------- // ----------------------------------------------------------------
// Rank history timeline component // Rank history timeline component
// ---------------------------------------------------------------- // ----------------------------------------------------------------
@@ -432,14 +416,10 @@ function MitgliedDetail() {
return ( return (
<DashboardLayout> <DashboardLayout>
<Container maxWidth="lg"> <Container maxWidth="lg">
{/* Back button */} <PageHeader
<Button title={displayName}
variant="text" backTo="/mitglieder"
onClick={() => navigate('/mitglieder')} />
sx={{ mb: 2 }}
>
Mitgliederliste
</Button>
{/* Header card */} {/* Header card */}
<Card sx={{ mb: 3 }}> <Card sx={{ mb: 3 }}>
@@ -459,10 +439,10 @@ function MitgliedDetail() {
{displayName} {displayName}
</Typography> </Typography>
{profile?.status && ( {profile?.status && (
<Chip <StatusChip
label={STATUS_LABELS[profile.status]} status={profile.status}
size="small" labelMap={STATUS_LABELS}
color={STATUS_COLORS[profile.status]} colorMap={STATUS_COLORS}
/> />
)} )}
</Box> </Box>
@@ -694,7 +674,7 @@ function MitgliedDetail() {
/> />
<FieldRow label="Status" value={ <FieldRow label="Status" value={
profile?.status profile?.status
? <Chip label={STATUS_LABELS[profile.status]} size="small" color={STATUS_COLORS[profile.status]} /> ? <StatusChip status={profile.status} labelMap={STATUS_LABELS} colorMap={STATUS_COLORS} />
: null : null
} /> } />
<FieldRow <FieldRow

View File

@@ -9,19 +9,12 @@ import {
Avatar, Avatar,
Tooltip, Tooltip,
Alert, Alert,
CircularProgress,
FormControl, FormControl,
InputLabel, InputLabel,
Select, Select,
MenuItem, MenuItem,
OutlinedInput, OutlinedInput,
SelectChangeEvent, SelectChangeEvent,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TablePagination, TablePagination,
Paper, Paper,
} from '@mui/material'; } from '@mui/material';
@@ -33,6 +26,7 @@ import {
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout'; import DashboardLayout from '../components/dashboard/DashboardLayout';
import ChatAwareFab from '../components/shared/ChatAwareFab'; import ChatAwareFab from '../components/shared/ChatAwareFab';
import { DataTable, StatusChip } from '../components/templates';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import { usePermissionContext } from '../contexts/PermissionContext'; import { usePermissionContext } from '../contexts/PermissionContext';
import { membersService } from '../services/members'; import { membersService } from '../services/members';
@@ -281,138 +275,60 @@ function Mitglieder() {
{/* Table */} {/* Table */}
<Paper sx={{ width: '100%', overflow: 'hidden' }}> <Paper sx={{ width: '100%', overflow: 'hidden' }}>
<TableContainer> <DataTable<MemberListItem>
<Table stickyHeader size="small" aria-label="Mitgliederliste"> columns={[
<TableHead> { key: 'profile_picture_url', label: 'Foto', width: 56, sortable: false, searchable: false, render: (member) => {
<TableRow>
<TableCell sx={{ width: 56 }}>Foto</TableCell>
<TableCell>Name</TableCell>
<TableCell>Stundenbuchnr.</TableCell>
<TableCell>Dienstgrad</TableCell>
<TableCell>Funktion</TableCell>
<TableCell>Status</TableCell>
<TableCell>Eintrittsdatum</TableCell>
<TableCell>Telefon</TableCell>
</TableRow>
</TableHead>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={8} align="center" sx={{ py: 6 }}>
<CircularProgress size={32} />
</TableCell>
</TableRow>
) : members.length === 0 ? (
<TableRow>
<TableCell colSpan={8} align="center" sx={{ py: 8 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1 }}>
<PeopleIcon sx={{ fontSize: 48, color: 'text.disabled' }} />
<Typography color="text.secondary">
Keine Mitglieder gefunden.
</Typography>
</Box>
</TableCell>
</TableRow>
) : (
members.map((member) => {
const displayName = getMemberDisplayName(member); const displayName = getMemberDisplayName(member);
const initials = [member.given_name?.[0], member.family_name?.[0]] const initials = [member.given_name?.[0], member.family_name?.[0]]
.filter(Boolean) .filter(Boolean).join('').toUpperCase() || member.email[0].toUpperCase();
.join('')
.toUpperCase() || member.email[0].toUpperCase();
return ( return (
<TableRow <Avatar src={member.profile_picture_url ?? undefined} alt={displayName} sx={{ width: 36, height: 36, fontSize: '0.875rem' }}>
key={member.id}
hover
onClick={() => handleRowClick(member.id)}
sx={{ cursor: 'pointer' }}
aria-label={`Mitglied ${displayName} öffnen`}
>
{/* Avatar */}
<TableCell>
<Avatar
src={member.profile_picture_url ?? undefined}
alt={displayName}
sx={{ width: 36, height: 36, fontSize: '0.875rem' }}
>
{initials} {initials}
</Avatar> </Avatar>
</TableCell> );
}},
{/* Name + email */} { key: 'family_name', label: 'Name', render: (member) => {
<TableCell> const displayName = getMemberDisplayName(member);
<Typography variant="body2" fontWeight={500}> return (
{displayName} <Box>
</Typography> <Typography variant="body2" fontWeight={500}>{displayName}</Typography>
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">{member.email}</Typography>
{member.email} </Box>
</Typography> );
</TableCell> }},
{ key: 'fdisk_standesbuch_nr', label: 'Stundenbuchnr.', render: (member) => member.fdisk_standesbuch_nr ?? '—' },
{/* Stundenbuchnr */} { key: 'dienstgrad', label: 'Dienstgrad', render: (member) => member.dienstgrad
<TableCell> ? <Chip label={member.dienstgrad} size="small" variant="outlined" />
<Typography variant="body2"> : <Typography variant="body2" color="text.secondary"></Typography>
{member.fdisk_standesbuch_nr ?? '—'} },
</Typography> { key: 'funktion', label: 'Funktion', sortable: false, render: (member) => (
</TableCell>
{/* Dienstgrad */}
<TableCell>
{member.dienstgrad ? (
<Chip label={member.dienstgrad} size="small" variant="outlined" />
) : (
<Typography variant="body2" color="text.secondary"></Typography>
)}
</TableCell>
{/* Funktion(en) */}
<TableCell>
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}> <Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{Array.isArray(member.funktion) && member.funktion.length > 0 {Array.isArray(member.funktion) && member.funktion.length > 0
? member.funktion.map((f) => ( ? member.funktion.map((f) => <Chip key={f} label={f} size="small" variant="outlined" color="secondary" />)
<Chip key={f} label={f} size="small" variant="outlined" color="secondary" />
))
: <Typography variant="body2" color="text.secondary"></Typography> : <Typography variant="body2" color="text.secondary"></Typography>
} }
</Box> </Box>
</TableCell> )},
{ key: 'status', label: 'Status', render: (member) => member.status
{/* Status */} ? <StatusChip status={member.status} labelMap={STATUS_LABELS} colorMap={STATUS_COLORS} />
<TableCell> : <Typography variant="body2" color="text.secondary"></Typography>
{member.status ? ( },
<Chip { key: 'eintrittsdatum', label: 'Eintrittsdatum', render: (member) => member.eintrittsdatum
label={STATUS_LABELS[member.status]}
size="small"
color={STATUS_COLORS[member.status]}
/>
) : (
<Typography variant="body2" color="text.secondary"></Typography>
)}
</TableCell>
{/* Eintrittsdatum */}
<TableCell>
<Typography variant="body2">
{member.eintrittsdatum
? new Date(member.eintrittsdatum).toLocaleDateString('de-AT') ? new Date(member.eintrittsdatum).toLocaleDateString('de-AT')
: '—'} : '—'
</Typography> },
</TableCell> { key: 'telefon_mobil', label: 'Telefon', render: (member) => formatPhone(member.telefon_mobil) },
]}
{/* Telefon */} data={members}
<TableCell> rowKey={(member) => member.id}
<Typography variant="body2"> onRowClick={(member) => handleRowClick(member.id)}
{formatPhone(member.telefon_mobil)} isLoading={loading}
</Typography> emptyMessage="Keine Mitglieder gefunden."
</TableCell> emptyIcon={<PeopleIcon sx={{ fontSize: 40, mb: 1, opacity: 0.5 }} />}
</TableRow> searchEnabled={false}
); paginationEnabled={false}
}) stickyHeader
)} />
</TableBody>
</Table>
</TableContainer>
<TablePagination <TablePagination
component="div" component="div"

View File

@@ -38,7 +38,6 @@ import {
useSortable, useSortable,
} from '@dnd-kit/sortable'; } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
import { useNavigate } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout'; import DashboardLayout from '../components/dashboard/DashboardLayout';
import { useThemeMode } from '../contexts/ThemeContext'; import { useThemeMode } from '../contexts/ThemeContext';
import { preferencesApi } from '../services/settings'; import { preferencesApi } from '../services/settings';

View File

@@ -12,7 +12,7 @@ import type {
ErinnerungFormData, ErinnerungFormData,
BestellungHistorie, BestellungHistorie,
} from '../types/bestellung.types'; } from '../types/bestellung.types';
import type { AusruestungArtikel, AusruestungEigenschaft } from '../types/ausruestungsanfrage.types'; import type { AusruestungArtikel } from '../types/ausruestungsanfrage.types';
export const bestellungApi = { export const bestellungApi = {
// ── Vendors ── // ── Vendors ──

View File

@@ -1,5 +1,8 @@
import { createTheme, ThemeOptions } from '@mui/material/styles'; import { createTheme, ThemeOptions } from '@mui/material/styles';
/** Golden ratio constant for proportional spacing/sizing */
export const GOLDEN_RATIO = 1.618;
// Fire department red color palette // Fire department red color palette
const primaryRed = { const primaryRed = {
main: '#d32f2f', main: '#d32f2f',
@@ -160,6 +163,34 @@ const lightThemeOptions: ThemeOptions = {
}, },
}, },
}, },
MuiTableHead: {
styleOverrides: {
root: {
'& .MuiTableCell-head': {
textTransform: 'uppercase',
fontSize: '0.75rem',
fontWeight: 600,
letterSpacing: '0.05em',
},
},
},
},
MuiTableRow: {
styleOverrides: {
root: {
'&.MuiTableRow-hover:hover': {
backgroundColor: 'rgba(0, 0, 0, 0.04)',
},
},
},
},
MuiDialog: {
styleOverrides: {
paper: {
borderRadius: 12,
},
},
},
}, },
}; };