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 <>
unwiderruflich geloescht. Dieser Vorgang kann nicht rueckgaengig gemacht 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.
)} </>
</DialogContentText> ) : ''}
</DialogContent> confirmLabel="Endgueltig loeschen"
<DialogActions> confirmColor="error"
<Button onClick={() => setConfirmDialog(null)} disabled={deleting}>Abbrechen</Button> isLoading={deleting}
<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 <>
unwiderruflich geloescht und die Nummerierung auf 1 zurueckgesetzt. Dieser Vorgang kann nicht rueckgaengig gemacht 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.
)} </>
</DialogContentText> ) : ''}
</DialogContent> confirmLabel="Endgueltig zuruecksetzen"
<DialogActions> confirmColor="error"
<Button onClick={() => setResetConfirmDialog(null)} disabled={resetDeleting}>Abbrechen</Button> isLoading={resetDeleting}
<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,38 +31,32 @@ 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: 'baseline', gap: 0.5, mb: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}> <Typography variant="h3" component="span" sx={{ fontWeight: 700 }}>
<MonitorHeartOutlined color={color} /> {up}
<Typography variant="h6" component="div"> </Typography>
Service Status <Typography variant="h5" component="span" color="text.secondary">
</Typography> / {total}
</Box> </Typography>
<Typography variant="body2" color="text.secondary" sx={{ ml: 0.5 }}>
Services online
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.5, mb: 1 }}> <Chip
<Typography variant="h3" component="span" sx={{ fontWeight: 700 }}> label={allUp ? 'Alle aktiv' : majorityDown ? 'Kritisch' : 'Teilweise gestört'}
{up} color={color}
</Typography> size="small"
<Typography variant="h5" component="span" color="text.secondary"> variant="outlined"
/ {total} />
</Typography> </WidgetCard>
<Typography variant="body2" color="text.secondary" sx={{ ml: 0.5 }}>
Services online
</Typography>
</Box>
<Chip
label={allUp ? 'Alle aktiv' : majorityDown ? 'Kritisch' : 'Teilweise gestört'}
color={color}
size="small"
variant="outlined"
/>
</CardContent>
</Card>
); );
} }

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', gap: 1, flexWrap: 'wrap' }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1.5 }}> {overview && overview.unhandled_count > 0 && (
<Typography variant="h6">Interne Bestellungen</Typography> <Chip label={`${overview.unhandled_count} Neu`} size="small" color="error" variant="outlined" />
<Build fontSize="small" color="action" />
</Box>
{!hasAny ? (
<Typography variant="body2" color="text.secondary">Keine Anfragen vorhanden.</Typography>
) : (
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
{overview.unhandled_count > 0 && (
<Chip label={`${overview.unhandled_count} Neu`} size="small" color="error" variant="outlined" />
)}
{overview.pending_count > 0 && (
<Chip label={`${overview.pending_count} Offen`} size="small" color="warning" variant="outlined" />
)}
{overview.approved_count > 0 && (
<Chip label={`${overview.approved_count} Genehmigt`} size="small" color="info" variant="outlined" />
)}
</Box>
)} )}
</CardContent> {overview && overview.pending_count > 0 && (
</Card> <Chip label={`${overview.pending_count} Offen`} size="small" color="warning" variant="outlined" />
)}
{overview && overview.approved_count > 0 && (
<Chip label={`${overview.approved_count} Genehmigt`} size="small" color="info" variant="outlined" />
)}
</Box>
</WidgetCard>
); );
} }

View File

@@ -1,8 +1,9 @@
import { Card, CardContent, Typography, Box } from '@mui/material'; import { Typography, Box } from '@mui/material';
import { Campaign } from '@mui/icons-material'; import { 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,29 +24,26 @@ 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 sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
</Box> {widgetBanners.map(banner => (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}> <Box
{widgetBanners.map(banner => ( key={banner.id}
<Box sx={{
key={banner.id} borderLeft: `4px solid ${SEVERITY_COLOR[banner.level]}`,
sx={{ pl: 2,
borderLeft: `4px solid ${SEVERITY_COLOR[banner.level]}`, py: 1,
pl: 2, }}
py: 1, >
}} <Typography variant="body2">
> {banner.message}
<Typography variant="body2"> </Typography>
{banner.message} </Box>
</Typography> ))}
</Box> </Box>
))} </WidgetCard>
</Box>
</CardContent>
</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,54 +28,30 @@ 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 (
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>Bestellungen</Typography>
<Skeleton variant="rectangular" height={40} />
</CardContent>
</Card>
);
}
if (isError) {
return (
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>Bestellungen</Typography>
<Typography variant="body2" color="text.secondary">
Bestellungen konnten nicht geladen werden.
</Typography>
</CardContent>
</Card>
);
}
return ( return (
<Card sx={{ cursor: 'pointer', '&:hover': { bgcolor: 'action.hover' } }} onClick={() => navigate('/bestellungen')}> <WidgetCard
<CardContent> title="Bestellungen"
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1.5 }}> icon={<LocalShipping fontSize="small" color="action" />}
<Typography variant="h6">Bestellungen</Typography> isLoading={isLoading}
<LocalShipping fontSize="small" color="action" /> skeleton={<ChipListSkeleton />}
</Box> isError={isError}
{statusCounts.length === 0 ? ( errorMessage="Bestellungen konnten nicht geladen werden."
<Typography variant="body2" color="text.secondary">Keine offenen Bestellungen</Typography> isEmpty={!isLoading && !isError && statusCounts.length === 0}
) : ( emptyMessage="Keine offenen Bestellungen"
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}> onClick={() => navigate('/bestellungen')}
{statusCounts.map(({ status, count }) => ( >
<Chip <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
key={status} {statusCounts.map(({ status, count }) => (
label={`${count} ${BESTELLUNG_STATUS_LABELS[status]}`} <Chip
color={BESTELLUNG_STATUS_COLORS[status]} key={status}
size="small" label={`${count} ${BESTELLUNG_STATUS_LABELS[status]}`}
variant="outlined" color={BESTELLUNG_STATUS_COLORS[status]}
/> size="small"
))} variant="outlined"
</Box> />
)} ))}
</CardContent> </Box>
</Card> </WidgetCard>
); );
} }

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,43 +24,39 @@ 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', px: 1,
py: 1.5, cursor: 'pointer',
px: 1, borderRadius: 1,
cursor: 'pointer', transition: 'background-color 0.15s ease',
borderRadius: 1, '&:hover': { bgcolor: 'action.hover' },
transition: 'background-color 0.15s ease', }}
'&:hover': { bgcolor: 'action.hover' }, >
}} <Box sx={{ flex: 1, minWidth: 0 }}>
> <Typography variant="subtitle2" noWrap>
<Box sx={{ flex: 1, minWidth: 0 }}> {page.name}
<Typography variant="subtitle2" noWrap> </Typography>
{page.name} {page.book && (
</Typography> <Typography variant="body2" color="text.secondary" noWrap sx={{ mt: 0.25 }}>
{page.book && ( {page.book.name}
<Typography variant="body2" color="text.secondary" noWrap sx={{ mt: 0.25 }}>
{page.book.name}
</Typography>
)}
</Box>
{relativeTime && (
<Typography
variant="caption"
color="text.secondary"
sx={{ ml: 1, whiteSpace: 'nowrap', mt: 0.25 }}
>
{relativeTime}
</Typography> </Typography>
)} )}
</Box> </Box>
{showDivider && <Divider />} {relativeTime && (
</> <Typography
variant="caption"
color="text.secondary"
sx={{ ml: 1, whiteSpace: 'nowrap', mt: 0.25 }}
>
{relativeTime}
</Typography>
)}
</Box>
); );
}; };
@@ -80,77 +72,44 @@ const BookStackRecentWidget: React.FC = () => {
const pages = (data?.data ?? []).slice(0, 5); 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,86 +120,64 @@ 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> <TextField
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}> fullWidth
<MenuBook color="primary" /> size="small"
<Typography variant="h6" sx={{ flexGrow: 1 }}> placeholder="Suchbegriff eingeben..."
Wissen Suche value={query}
</Typography> onChange={(e) => setQuery(e.target.value)}
InputProps={{
startAdornment: (
<InputAdornment position="start">
{searching ? <CircularProgress size={16} /> : <Search fontSize="small" />}
</InputAdornment>
),
}}
/>
{!searching && query.trim() && results.length === 0 && (
<Typography variant="body2" color="text.secondary" sx={{ py: 2, textAlign: 'center' }}>
Keine Ergebnisse für \u201e{query}\u201c
</Typography>
)}
{results.length > 0 && (
<Box sx={{ mt: 1, maxHeight: 300, overflow: 'auto' }}>
{results.map((result, index) => (
<ResultRow
key={result.id}
result={result}
showDivider={index < results.length - 1}
/>
))}
</Box> </Box>
)}
<TextField </WidgetCard>
fullWidth
size="small"
placeholder="Suchbegriff eingeben..."
value={query}
onChange={(e) => setQuery(e.target.value)}
InputProps={{
startAdornment: (
<InputAdornment position="start">
{searching ? <CircularProgress size={16} /> : <Search fontSize="small" />}
</InputAdornment>
),
}}
/>
{!searching && query.trim() && results.length === 0 && (
<Typography variant="body2" color="text.secondary" sx={{ py: 2, textAlign: 'center' }}>
Keine Ergebnisse für {query}"
</Typography>
)}
{results.length > 0 && (
<Box sx={{ mt: 1, maxHeight: 300, overflow: 'auto' }}>
{results.map((result, index) => (
<ResultRow
key={result.id}
result={result}
showDivider={index < results.length - 1}
/>
))}
</Box>
)}
</CardContent>
</Card>
); );
}; };

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,76 +32,40 @@ 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)}
<Box sx={{ mb: 0.5 }}> errorMessage={!activeJahr ? 'Kein aktives Haushaltsjahr' : 'Daten konnten nicht geladen werden'}
<Typography variant="body2" color="text.secondary" component="span">Einnahmen: </Typography> isEmpty={!isLoading && !isError && !!activeJahr && !stats}
<Typography variant="body2" color="success.main" component="span" fontWeight={600}>{fmtEur(stats.total_einnahmen)}</Typography> onClick={() => navigate('/buchhaltung')}
</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">Einnahmen: </Typography>
<Typography variant="body2" color="error.main" component="span" fontWeight={600}>{fmtEur(stats.total_ausgaben)}</Typography> <Typography variant="body2" color="success.main" component="span" fontWeight={600}>{fmtEur(stats?.total_einnahmen ?? 0)}</Typography>
</Box> </Box>
<Box sx={{ mb: overBudgetCount > 0 ? 1 : 0 }}> <Box sx={{ mb: 0.5 }}>
<Typography variant="body2" color="text.secondary" component="span">Saldo: </Typography> <Typography variant="body2" color="text.secondary" component="span">Ausgaben: </Typography>
<Typography variant="body2" color={stats.saldo >= 0 ? 'success.main' : 'error.main'} component="span" fontWeight={600}>{fmtEur(stats.saldo)}</Typography> <Typography variant="body2" color="error.main" component="span" fontWeight={600}>{fmtEur(stats?.total_ausgaben ?? 0)}</Typography>
</Box> </Box>
{overBudgetCount > 0 && ( <Box sx={{ mb: overBudgetCount > 0 ? 1 : 0 }}>
<Chip <Typography variant="body2" color="text.secondary" component="span">Saldo: </Typography>
label={`${overBudgetCount} Konto${overBudgetCount > 1 ? 'n' : ''} über 80% Budget`} <Typography variant="body2" color={(stats?.saldo ?? 0) >= 0 ? 'success.main' : 'error.main'} component="span" fontWeight={600}>{fmtEur(stats?.saldo ?? 0)}</Typography>
color="warning" </Box>
size="small" {overBudgetCount > 0 && (
variant="outlined" <Chip
/> label={`${overBudgetCount} Konto${overBudgetCount > 1 ? 'n' : ''} über 80% Budget`}
)} color="warning"
</CardContent> size="small"
</Card> variant="outlined"
/>
)}
</WidgetCard>
); );
} }

View File

@@ -1,8 +1,10 @@
import { Card, CardContent, Typography, Box, Chip, Skeleton } from '@mui/material'; import { Typography, Box, Chip } from '@mui/material';
import { AssignmentTurnedIn, Warning } from '@mui/icons-material'; import { 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,77 +18,48 @@ 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 sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
</Box> {overdueItems.slice(0, 5).map((item) => {
<AssignmentTurnedIn fontSize="small" color="action" /> const days = Math.ceil((Date.now() - new Date(item.naechste_faellig_am).getTime()) / 86400000);
</Box> const targetName = item.fahrzeug_name || item.ausruestung_name || '';
{overdueItems.length === 0 ? ( return (
<Typography variant="body2" color="text.secondary">Alle Checklisten aktuell</Typography> <Box key={`${item.fahrzeug_id || item.ausruestung_id}-${item.vorlage_id}`} sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
) : ( <Typography variant="body2" noWrap sx={{ maxWidth: '60%' }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}> {targetName}
{overdueItems.slice(0, 5).map((item) => {
const days = Math.ceil((Date.now() - new Date(item.naechste_faellig_am).getTime()) / 86400000);
const targetName = item.fahrzeug_name || item.ausruestung_name || '';
return (
<Box key={`${item.fahrzeug_id || item.ausruestung_id}-${item.vorlage_id}`} sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="body2" noWrap sx={{ maxWidth: '60%' }}>
{targetName}
</Typography>
<Chip
label={`${item.vorlage_name} \u2013 ${days > 0 ? `${days}d` : 'heute'}`}
color={days > 7 ? 'error' : days > 0 ? 'warning' : 'info'}
size="small"
variant="outlined"
/>
</Box>
);
})}
{overdueItems.length > 5 && (
<Typography variant="caption" color="text.secondary">
+ {overdueItems.length - 5} weitere
</Typography> </Typography>
)} <Chip
</Box> label={`${item.vorlage_name} \u2013 ${days > 0 ? `${days}d` : 'heute'}`}
color={days > 7 ? 'error' : days > 0 ? 'warning' : 'info'}
size="small"
variant="outlined"
/>
</Box>
);
})}
{overdueItems.length > 5 && (
<Typography variant="caption" color="text.secondary">
+ {overdueItems.length - 5} weitere
</Typography>
)} )}
</CardContent> </Box>
</Card> </WidgetCard>
); );
} }

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,93 +109,74 @@ 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> <TextField
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}> fullWidth
<CalendarMonth color="primary" /> size="small"
<Typography variant="h6">Veranstaltung</Typography> label="Titel"
</Box> value={titel}
onChange={(e) => setTitel(e.target.value)}
required
inputProps={{ maxLength: 250 }}
/>
<Box component="form" onSubmit={handleSubmit} sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}> <FormControlLabel
<TextField control={
fullWidth <Switch
size="small" checked={ganztaegig}
label="Titel" onChange={(e) => setGanztaegig(e.target.checked)}
value={titel} size="small"
onChange={(e) => setTitel(e.target.value)} />
required }
inputProps={{ maxLength: 250 }} label={<Typography variant="body2">Ganztägig</Typography>}
/> sx={{ mx: 0 }}
/>
<FormControlLabel <TextField
control={ fullWidth
<Switch size="small"
checked={ganztaegig} label="Datum von"
onChange={(e) => setGanztaegig(e.target.checked)} type={dateFieldType}
size="small" value={datumVonValue}
/> onChange={(e) => {
} const val = e.target.value;
label={<Typography variant="body2">Ganztägig</Typography>} setDatumVon(ganztaegig ? val + 'T00:00' : val);
sx={{ mx: 0 }} }}
/> required
InputLabelProps={{ shrink: true }}
/>
<TextField <TextField
fullWidth fullWidth
size="small" size="small"
label="Datum von" label="Datum bis"
type={dateFieldType} type={dateFieldType}
value={datumVonValue} value={datumBisValue}
onChange={(e) => { onChange={(e) => {
const val = e.target.value; const val = e.target.value;
setDatumVon(ganztaegig ? val + 'T00:00' : val); setDatumBis(ganztaegig ? val + 'T00:00' : val);
}} }}
required required
InputLabelProps={{ shrink: true }} InputLabelProps={{ shrink: true }}
/> />
<TextField <TextField
fullWidth fullWidth
size="small" size="small"
label="Datum bis" label="Beschreibung (optional)"
type={dateFieldType} value={beschreibung}
value={datumBisValue} onChange={(e) => setBeschreibung(e.target.value)}
onChange={(e) => { multiline
const val = e.target.value; rows={2}
setDatumBis(ganztaegig ? val + 'T00:00' : val); inputProps={{ maxLength: 1000 }}
}} />
required </FormCard>
InputLabelProps={{ shrink: true }}
/>
<TextField
fullWidth
size="small"
label="Beschreibung (optional)"
value={beschreibung}
onChange={(e) => setBeschreibung(e.target.value)}
multiline
rows={2}
inputProps={{ maxLength: 1000 }}
/>
<Button
type="submit"
variant="contained"
size="small"
disabled={!titel.trim() || !datumVon || !datumBis || mutation.isPending}
fullWidth
>
{mutation.isPending ? 'Wird erstellt…' : 'Erstellen'}
</Button>
</Box>
</CardContent>
</Card>
); );
}; };

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,65 +16,31 @@ 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 (
<Card sx={{ cursor: 'pointer' }} onClick={() => navigate('/issues')}>
<CardContent>
<Typography variant="h6" gutterBottom>Issues</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: 'text.secondary' }}>
<BugReport fontSize="small" />
<Typography variant="body2">Keine offenen Issues</Typography>
</Box>
</CardContent>
</Card>
);
}
return ( return (
<Card sx={{ cursor: 'pointer' }} onClick={() => navigate('/issues')}> <WidgetCard
<CardContent> title="Issues"
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1.5 }}> icon={<BugReport fontSize="small" color="action" />}
<Typography variant="h6">Issues</Typography> isLoading={isLoading}
<BugReport fontSize="small" color="action" /> skeleton={<ChipListSkeleton />}
</Box> isError={isError}
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}> errorMessage="Issues konnten nicht geladen werden."
{visibleCounts.map((s) => ( isEmpty={!isLoading && !isError && visibleCounts.length === 0}
<Chip emptyMessage="Keine offenen Issues"
key={s.schluessel} onClick={() => navigate('/issues')}
label={`${s.count} ${s.bezeichnung}`} >
color={s.farbe as any} <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
size="small" {visibleCounts.map((s) => (
/> <Chip
))} key={s.schluessel}
</Box> label={`${s.count} ${s.bezeichnung}`}
</CardContent> color={s.farbe as any}
</Card> size="small"
/>
))}
</Box>
</WidgetCard>
); );
} }

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

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,35 +11,27 @@ 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> <Link
<Divider sx={{ mb: 1.5 }} /> href={link.url}
<Stack spacing={0.5}> target="_blank"
{collection.links.map((link, i) => ( rel="noopener noreferrer"
<Link underline="hover"
key={i} sx={{
href={link.url} display: 'flex',
target="_blank" alignItems: 'center',
rel="noopener noreferrer" gap: 0.5,
underline="hover" }}
sx={{ >
display: 'flex', {link.name}
alignItems: 'center', <OpenInNew sx={{ fontSize: 14, opacity: 0.6 }} />
gap: 0.5, </Link>
py: 0.5, )}
}} />
>
{link.name}
<OpenInNew sx={{ fontSize: 14, opacity: 0.6 }} />
</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={{ border: '1px solid',
fontWeight: 700, borderColor: overdue.length > 0 ? 'error.main' : 'warning.main',
color: overdue.length > 0 ? 'error.dark' : 'warning.dark', }}
}} >
> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
Persönliche Warnungen ({warnings.length})
</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,79 +25,49 @@ 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', <Box
'&:hover': { sx={{
transform: 'translateY(-4px)', display: 'flex',
boxShadow: 4, alignItems: 'center',
}, bgcolor: isConnected ? 'success.light' : 'grey.300',
}} borderRadius: '50%',
> p: 1,
<CardActionArea }}
onClick={onClick} >
sx={{ <Icon
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'stretch',
justifyContent: 'flex-start',
}}
>
<CardContent sx={{ flexGrow: 1, width: '100%' }}>
<Box
sx={{ sx={{
display: 'flex', fontSize: 24,
alignItems: 'center', color: isConnected ? 'success.dark' : 'grey.600',
justifyContent: 'space-between',
mb: 2,
}} }}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
bgcolor: isConnected ? 'success.light' : 'grey.300',
borderRadius: '50%',
p: 1.5,
}}
>
<Icon
sx={{
fontSize: 32,
color: isConnected ? 'success.dark' : 'grey.600',
}}
/>
</Box>
<Box
sx={{
width: 12,
height: 12,
borderRadius: '50%',
bgcolor: isConnected ? 'success.main' : 'grey.400',
}}
/>
</Box>
<Typography variant="h6" component="div" gutterBottom>
{title}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
{description}
</Typography>
<Chip
label={isConnected ? 'Verbunden' : 'Noch nicht konfiguriert'}
size="small"
color={isConnected ? 'success' : 'default'}
variant={isConnected ? 'filled' : 'outlined'}
/> />
</CardContent> </Box>
</CardActionArea> }
</Card> action={
<Box
sx={{
width: 12,
height: 12,
borderRadius: '50%',
bgcolor: isConnected ? 'success.main' : 'grey.400',
}}
/>
}
onClick={onClick}
>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
{description}
</Typography>
<Chip
label={isConnected ? 'Verbunden' : 'Noch nicht konfiguriert'}
size="small"
color={isConnected ? 'success' : 'default'}
variant={isConnected ? 'filled' : 'outlined'}
/>
</WidgetCard>
); );
}; };

View File

@@ -1,74 +1,3 @@
import React from 'react'; // 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,153 +133,110 @@ 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
sx={{ title="Nächste Termine"
height: '100%', icon={<CalendarMonthIcon fontSize="small" sx={{ color: 'primary.main' }} />}
transition: 'box-shadow 0.3s ease', items={entries}
'&:hover': { boxShadow: 3 }, renderItem={(entry) => (
}} <Box
> sx={{
<CardContent sx={{ pb: '8px !important' }}> display: 'flex',
{/* Header */} alignItems: 'flex-start',
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}> gap: 1.5,
<CalendarMonthIcon fontSize="small" sx={{ color: 'primary.main' }} /> }}
<Typography variant="h6" component="div"> >
Nächste Termine {/* Colored type indicator dot */}
</Typography> <Box
</Box> sx={{
mt: '4px',
flexShrink: 0,
width: 10,
height: 10,
borderRadius: '50%',
bgcolor: entry.color,
}}
/>
<Divider sx={{ mb: 1 }} /> {/* Date + title block */}
<Box sx={{ flex: 1, minWidth: 0 }}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.75,
flexWrap: 'wrap',
mb: 0.25,
}}
>
<Typography
variant="caption"
sx={{
color: 'text.secondary',
fontVariantNumeric: 'tabular-nums',
whiteSpace: 'nowrap',
}}
>
{formatDateShort(entry.date)}
{!entry.allDay && (
<> &middot; {formatTime(entry.date.toISOString())}</>
)}
</Typography>
{/* Empty state */} <Chip
{entries.length === 0 ? ( label={entry.typeLabel}
<Box sx={{ textAlign: 'center', py: 3 }}> size="small"
<Typography variant="body2" color="text.secondary"> sx={{
Keine bevorstehenden Termine height: 16,
fontSize: '0.65rem',
bgcolor: `${entry.color}22`,
color: entry.color,
border: `1px solid ${entry.color}55`,
fontWeight: 600,
'& .MuiChip-label': { px: '5px' },
}}
/>
</Box>
<Typography
variant="body2"
sx={{
fontWeight: 500,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{entry.title}
</Typography> </Typography>
</Box> </Box>
) : ( </Box>
<List disablePadding> )}
{entries.map((entry, index) => ( isLoading={loading}
<React.Fragment key={entry.id}> skeletonCount={5}
<ListItem skeletonItem={
disableGutters <Box sx={{ display: 'flex', gap: 1.5, alignItems: 'flex-start' }}>
sx={{ <Skeleton variant="circular" width={10} height={10} sx={{ mt: '4px' }} animation="wave" />
py: 1, <Box sx={{ flex: 1 }}>
display: 'flex', <Skeleton variant="text" width="70%" animation="wave" />
alignItems: 'flex-start', <Skeleton variant="text" width="40%" animation="wave" />
gap: 1.5, </Box>
}} </Box>
> }
{/* Colored type indicator dot */} emptyMessage="Keine bevorstehenden Termine"
<Box footer={
sx={{
mt: '4px',
flexShrink: 0,
width: 10,
height: 10,
borderRadius: '50%',
bgcolor: entry.color,
}}
/>
{/* Date + title block */}
<Box sx={{ flex: 1, minWidth: 0 }}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.75,
flexWrap: 'wrap',
mb: 0.25,
}}
>
<Typography
variant="caption"
sx={{
color: 'text.secondary',
fontVariantNumeric: 'tabular-nums',
whiteSpace: 'nowrap',
}}
>
{formatDateShort(entry.date)}
{!entry.allDay && (
<> &middot; {formatTime(entry.date.toISOString())}</>
)}
</Typography>
<Chip
label={entry.typeLabel}
size="small"
sx={{
height: 16,
fontSize: '0.65rem',
bgcolor: `${entry.color}22`,
color: entry.color,
border: `1px solid ${entry.color}55`,
fontWeight: 600,
'& .MuiChip-label': { px: '5px' },
}}
/>
</Box>
<Typography
variant="body2"
sx={{
fontWeight: 500,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{entry.title}
</Typography>
</Box>
</ListItem>
{index < entries.length - 1 && (
<Divider component="li" sx={{ listStyle: 'none' }} />
)}
</React.Fragment>
))}
</List>
)}
{/* Footer link */}
<Divider sx={{ mt: 1, mb: 1 }} />
<Box 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,169 +52,125 @@ 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) => {
}} const color = BUCHUNGS_ART_COLORS[booking.buchungs_art] ?? '#9e9e9e';
> const label = BUCHUNGS_ART_LABELS[booking.buchungs_art] ?? booking.buchungs_art;
<CardContent sx={{ pb: '8px !important' }}> return (
{/* Header */} <Box
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}> sx={{
<DirectionsCarIcon fontSize="small" sx={{ color: 'primary.main' }} /> display: 'flex',
<Typography variant="h6" component="div"> alignItems: 'flex-start',
Nächste Fahrzeugbuchungen gap: 1.5,
</Typography> }}
</Box> >
{/* Colored type indicator dot */}
<Box
sx={{
mt: '4px',
flexShrink: 0,
width: 10,
height: 10,
borderRadius: '50%',
bgcolor: color,
}}
/>
<Divider sx={{ mb: 1 }} /> {/* Date + title block */}
<Box sx={{ flex: 1, minWidth: 0 }}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.75,
flexWrap: 'wrap',
mb: 0.25,
}}
>
<Typography
variant="caption"
sx={{
color: 'text.secondary',
fontVariantNumeric: 'tabular-nums',
whiteSpace: 'nowrap',
}}
>
{formatDateShort(booking.beginn)}
{' '}&middot;{' '}
{formatTime(booking.beginn)}
</Typography>
{/* Empty state */} <Chip
{items.length === 0 ? ( label={label}
<Box sx={{ textAlign: 'center', py: 3 }}> size="small"
<Typography variant="body2" color="text.secondary"> sx={{
Keine bevorstehenden Buchungen height: 16,
</Typography> fontSize: '0.65rem',
bgcolor: `${color}22`,
color: color,
border: `1px solid ${color}55`,
fontWeight: 600,
'& .MuiChip-label': { px: '5px' },
}}
/>
</Box>
<Typography
variant="body2"
sx={{
fontWeight: 500,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{booking.titel}
</Typography>
<Typography
variant="caption"
color="text.secondary"
sx={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
display: 'block',
}}
>
{booking.fahrzeug_name}
</Typography>
</Box>
</Box> </Box>
) : ( );
<List disablePadding> }}
{items.map((booking, index) => { isLoading={isLoading}
const color = BUCHUNGS_ART_COLORS[booking.buchungs_art] ?? '#9e9e9e'; skeletonCount={5}
const label = BUCHUNGS_ART_LABELS[booking.buchungs_art] ?? booking.buchungs_art; skeletonItem={
return ( <Box sx={{ display: 'flex', gap: 1.5, alignItems: 'flex-start' }}>
<React.Fragment key={booking.id}> <Skeleton variant="circular" width={10} height={10} sx={{ mt: '4px' }} animation="wave" />
<ListItem <Box sx={{ flex: 1 }}>
disableGutters <Skeleton variant="text" width="70%" animation="wave" />
sx={{ <Skeleton variant="text" width="40%" animation="wave" />
py: 1, </Box>
display: 'flex', </Box>
alignItems: 'flex-start', }
gap: 1.5, emptyMessage="Keine bevorstehenden Buchungen"
}} footer={
>
{/* Colored type indicator dot */}
<Box
sx={{
mt: '4px',
flexShrink: 0,
width: 10,
height: 10,
borderRadius: '50%',
bgcolor: color,
}}
/>
{/* Date + title block */}
<Box sx={{ flex: 1, minWidth: 0 }}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.75,
flexWrap: 'wrap',
mb: 0.25,
}}
>
<Typography
variant="caption"
sx={{
color: 'text.secondary',
fontVariantNumeric: 'tabular-nums',
whiteSpace: 'nowrap',
}}
>
{formatDateShort(booking.beginn)}
{' '}&middot;{' '}
{formatTime(booking.beginn)}
</Typography>
<Chip
label={label}
size="small"
sx={{
height: 16,
fontSize: '0.65rem',
bgcolor: `${color}22`,
color: color,
border: `1px solid ${color}55`,
fontWeight: 600,
'& .MuiChip-label': { px: '5px' },
}}
/>
</Box>
<Typography
variant="body2"
sx={{
fontWeight: 500,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{booking.titel}
</Typography>
<Typography
variant="caption"
color="text.secondary"
sx={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
display: 'block',
}}
>
{booking.fahrzeug_name}
</Typography>
</Box>
</ListItem>
{index < items.length - 1 && (
<Divider component="li" sx={{ listStyle: 'none' }} />
)}
</React.Fragment>
);
})}
</List>
)}
{/* Footer link */}
<Divider sx={{ mt: 1, mb: 1 }} />
<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,98 +91,81 @@ const VehicleBookingQuickAddWidget: React.FC = () => {
mutation.mutate(); mutation.mutate();
}; };
if (vehiclesLoading) {
return (
<WidgetCard
title="Fahrzeugbuchung"
icon={<DirectionsCar color="primary" />}
isLoading
skeleton={<FormSkeleton />}
/>
);
}
return ( return (
<Card <FormCard
sx={{ title="Fahrzeugbuchung"
height: '100%', icon={<DirectionsCar color="primary" />}
transition: 'all 0.3s ease', isSubmitting={mutation.isPending}
'&:hover': { boxShadow: 3 }, onSubmit={handleSubmit}
}} submitLabel="Erstellen"
> >
<CardContent> <FormControl fullWidth size="small">
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}> <InputLabel>Fahrzeug</InputLabel>
<DirectionsCar color="primary" /> <Select
<Typography variant="h6">Fahrzeugbuchung</Typography> value={fahrzeugId}
</Box> label="Fahrzeug"
onChange={(e: SelectChangeEvent<string>) => setFahrzeugId(e.target.value)}
>
{(vehicles ?? []).map((v) => (
<MenuItem key={v.id} value={v.id}>
{v.bezeichnung}
</MenuItem>
))}
</Select>
</FormControl>
{vehiclesLoading ? ( <TextField
<Box> fullWidth
<Skeleton variant="rectangular" height={40} sx={{ mb: 1.5, borderRadius: 1 }} /> size="small"
<Skeleton variant="rectangular" height={40} sx={{ mb: 1.5, borderRadius: 1 }} /> label="Titel"
<Skeleton variant="rectangular" height={40} sx={{ mb: 1.5, borderRadius: 1 }} /> value={titel}
<Skeleton variant="rectangular" height={40} sx={{ borderRadius: 1 }} /> onChange={(e) => setTitel(e.target.value)}
</Box> required
) : ( inputProps={{ maxLength: 250 }}
<Box component="form" onSubmit={handleSubmit} sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}> />
<FormControl fullWidth size="small">
<InputLabel>Fahrzeug</InputLabel>
<Select
value={fahrzeugId}
label="Fahrzeug"
onChange={(e: SelectChangeEvent<string>) => setFahrzeugId(e.target.value)}
>
{(vehicles ?? []).map((v) => (
<MenuItem key={v.id} value={v.id}>
{v.bezeichnung}
</MenuItem>
))}
</Select>
</FormControl>
<TextField <GermanDateField
fullWidth mode="datetime"
size="small" fullWidth
label="Titel" size="small"
value={titel} label="Beginn"
onChange={(e) => setTitel(e.target.value)} value={beginn}
required onChange={(v) => setBeginn(v)}
inputProps={{ maxLength: 250 }} required
/> />
<GermanDateField <GermanDateField
mode="datetime" mode="datetime"
fullWidth fullWidth
size="small" size="small"
label="Beginn" label="Ende"
value={beginn} value={ende}
onChange={(v) => setBeginn(v)} onChange={(v) => setEnde(v)}
required required
/> />
<GermanDateField <TextField
mode="datetime" fullWidth
fullWidth size="small"
size="small" label="Beschreibung (optional)"
label="Ende" value={beschreibung}
value={ende} onChange={(e) => setBeschreibung(e.target.value)}
onChange={(v) => setEnde(v)} multiline
required rows={2}
/> inputProps={{ maxLength: 1000 }}
/>
<TextField </FormCard>
fullWidth
size="small"
label="Beschreibung (optional)"
value={beschreibung}
onChange={(e) => setBeschreibung(e.target.value)}
multiline
rows={2}
inputProps={{ maxLength: 1000 }}
/>
<Button
type="submit"
variant="contained"
size="small"
disabled={!fahrzeugId || !titel.trim() || !beginn || !ende || mutation.isPending}
fullWidth
>
{mutation.isPending ? 'Wird erstellt…' : 'Erstellen'}
</Button>
</Box>
)}
</CardContent>
</Card>
); );
}; };

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,47 +40,43 @@ 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', px: 1,
py: 1.5, cursor: 'pointer',
px: 1, borderRadius: 1,
cursor: 'pointer', transition: 'background-color 0.15s ease',
borderRadius: 1, '&:hover': { bgcolor: 'action.hover' },
transition: 'background-color 0.15s ease', }}
'&:hover': { bgcolor: 'action.hover' }, >
}} <Box sx={{ flex: 1, minWidth: 0 }}>
> <Typography variant="subtitle2" noWrap>
<Box sx={{ flex: 1, minWidth: 0 }}> {task.title}
<Typography variant="subtitle2" noWrap> </Typography>
{task.title} <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.5, flexWrap: 'wrap' }}>
</Typography> {dueDateStr && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.5, flexWrap: 'wrap' }}> <Typography
{dueDateStr && ( variant="caption"
<Typography color={overdue ? 'error' : 'text.secondary'}
variant="caption" >
color={overdue ? 'error' : 'text.secondary'} {dueDateStr}
> </Typography>
{dueDateStr} )}
</Typography> {task.priority > 0 && (
)} <Chip
{task.priority > 0 && ( label={priority.label}
<Chip size="small"
label={priority.label} color={priority.color}
size="small" sx={{ height: 18, fontSize: '0.65rem' }}
color={priority.color} />
sx={{ height: 18, fontSize: '0.65rem' }} )}
/>
)}
</Box>
</Box> </Box>
</Box> </Box>
{showDivider && <Divider />} </Box>
</>
); );
}; };
@@ -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 (
<WidgetCard
title="Meine Aufgaben"
icon={<AssignmentInd color="primary" />}
isError
errorMessage="Vikunja nicht erreichbar"
/>
);
}
const titleAction = !isLoading && !isError && tasks.length > 0 ? (
<Chip label={animatedTaskCount} size="small" color="primary" />
) : undefined;
return ( return (
<Card <ListCard
sx={{ title="Meine Aufgaben"
height: '100%', icon={<AssignmentInd color="primary" />}
transition: 'all 0.3s ease', action={titleAction}
'&:hover': { boxShadow: 3 }, items={tasks}
}} renderItem={(task) => (
> <TaskRow key={task.id} task={task} vikunjaUrl={data?.vikunjaUrl ?? ''} />
<CardContent> )}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}> isLoading={isLoading}
<AssignmentInd color="primary" /> skeletonCount={3}
<Typography variant="h6" sx={{ flexGrow: 1 }}> skeletonItem={
Meine Aufgaben <Box sx={{ mb: 0.5 }}>
</Typography> <Skeleton variant="text" width="70%" height={22} animation="wave" />
{!isLoading && !isError && tasks.length > 0 && ( <Skeleton variant="text" width="40%" height={18} animation="wave" />
<Chip label={animatedTaskCount} size="small" color="primary" />
)}
</Box> </Box>
}
{isLoading && ( emptyMessage="Keine offenen Aufgaben"
<Box> />
{[1, 2, 3].map((n) => (
<Box key={n} sx={{ mb: 1.5 }}>
<Skeleton variant="text" width="70%" height={22} />
<Skeleton variant="text" width="40%" height={18} />
</Box>
))}
</Box>
)}
{isError && (
<Typography variant="body2" color="text.secondary" sx={{ py: 2, textAlign: 'center' }}>
Vikunja nicht erreichbar
</Typography>
)}
{!isLoading && !isError && tasks.length === 0 && (
<Typography variant="body2" color="text.secondary" sx={{ py: 2, textAlign: 'center' }}>
Keine offenen Aufgaben
</Typography>
)}
{!isLoading && !isError && tasks.length > 0 && (
<Box>
{tasks.map((task, index) => (
<TaskRow
key={task.id}
task={task}
showDivider={index < tasks.length - 1}
vikunjaUrl={data?.vikunjaUrl ?? ''}
/>
))}
</Box>
)}
</CardContent>
</Card>
); );
}; };

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,92 +60,69 @@ 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> <FormControl fullWidth size="small">
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}> <InputLabel>Projekt</InputLabel>
<AddTask color="primary" /> <Select
<Typography variant="h6">Aufgabe erstellen</Typography> value={projectId}
</Box> label="Projekt"
onChange={(e: SelectChangeEvent<number | ''>) =>
setProjectId(e.target.value as number | '')
}
>
{projects.map((p) => (
<MenuItem key={p.id} value={p.id}>
{p.title}
</MenuItem>
))}
</Select>
</FormControl>
{projectsLoading ? ( <TextField
<Box> fullWidth
<Skeleton variant="rectangular" height={40} sx={{ mb: 1.5, borderRadius: 1 }} /> size="small"
<Skeleton variant="rectangular" height={40} sx={{ mb: 1.5, borderRadius: 1 }} /> label="Titel"
<Skeleton variant="rectangular" height={40} sx={{ borderRadius: 1 }} /> value={title}
</Box> onChange={(e) => setTitle(e.target.value)}
) : ( inputProps={{ maxLength: 250 }}
<Box component="form" onSubmit={handleSubmit} sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}> />
<FormControl fullWidth size="small">
<InputLabel>Projekt</InputLabel>
<Select
value={projectId}
label="Projekt"
onChange={(e: SelectChangeEvent<number | ''>) =>
setProjectId(e.target.value as number | '')
}
>
{projects.map((p) => (
<MenuItem key={p.id} value={p.id}>
{p.title}
</MenuItem>
))}
</Select>
</FormControl>
<TextField <GermanDateField
fullWidth mode="date"
size="small" fullWidth
label="Titel" size="small"
value={title} label="Fälligkeitsdatum (optional)"
onChange={(e) => setTitle(e.target.value)} value={dueDate}
inputProps={{ maxLength: 250 }} onChange={(v) => setDueDate(v)}
/> />
</FormCard>
<GermanDateField
mode="date"
fullWidth
size="small"
label="Fälligkeitsdatum (optional)"
value={dueDate}
onChange={(v) => setDueDate(v)}
/>
<Button
type="submit"
variant="contained"
size="small"
disabled={!title.trim() || projectId === '' || mutation.isPending}
fullWidth
>
{mutation.isPending ? 'Wird erstellt…' : 'Erstellen'}
</Button>
</Box>
)}
</CardContent>
</Card>
); );
}; };

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> <Tooltip title={item.lehrgang_datum ? `Lehrgang am ${formatDate(item.lehrgang_datum)}` : 'Lehrgang absolviert'}>
<TableCell>Leistungstest gültig bis</TableCell> <Check color="success" fontSize="small" />
<TableCell align="center">Status</TableCell> </Tooltip>
{canWrite && <TableCell align="right">Aktionen</TableCell>} ) : (
</TableRow> <Close color="disabled" fontSize="small" />
</TableHead> )
<TableBody> )},
{filtered.map((item) => { { key: 'untersuchung_gueltig_bis', label: 'Untersuchung gültig bis', render: (item) => (
const untersuchungColor = getValidityColor( <Tooltip title={
item.untersuchung_gueltig_bis, item.untersuchung_tage_rest !== null
item.untersuchung_tage_rest, ? item.untersuchung_tage_rest < 0
90 ? `Seit ${Math.abs(item.untersuchung_tage_rest)} Tagen abgelaufen`
); : `Noch ${item.untersuchung_tage_rest} Tage gültig`
const leistungstestColor = getValidityColor( : 'Keine Untersuchung eingetragen'
item.leistungstest_gueltig_bis, }>
item.leistungstest_tage_rest, <Typography variant="body2" color={getValidityColor(item.untersuchung_gueltig_bis, item.untersuchung_tage_rest, 90)} fontWeight={500}>
30 {formatDate(item.untersuchung_gueltig_bis)}
); </Typography>
</Tooltip>
)},
{ key: 'leistungstest_gueltig_bis', label: 'Leistungstest gültig bis', render: (item) => (
<Tooltip title={
item.leistungstest_tage_rest !== null
? item.leistungstest_tage_rest < 0
? `Seit ${Math.abs(item.leistungstest_tage_rest)} Tagen abgelaufen`
: `Noch ${item.leistungstest_tage_rest} Tage gültig`
: 'Kein Leistungstest eingetragen'
}>
<Typography variant="body2" color={getValidityColor(item.leistungstest_gueltig_bis, item.leistungstest_tage_rest, 30)} fontWeight={500}>
{formatDate(item.leistungstest_gueltig_bis)}
</Typography>
</Tooltip>
)},
{ key: 'einsatzbereit', label: 'Status', align: 'center', render: (item) => (
<Chip
label={item.einsatzbereit ? 'Einsatzbereit' : 'Nicht einsatzbereit'}
color={item.einsatzbereit ? 'success' : 'error'}
size="small"
variant="filled"
/>
)},
];
return ( if (canWrite) {
<TableRow key={item.id} hover> columns.push({
<TableCell> key: 'actions', label: 'Aktionen', align: 'right', sortable: false, searchable: false, render: (item) => (
<Typography variant="body2" fontWeight={500}> <>
{getDisplayName(item)} <Tooltip title="Bearbeiten">
</Typography> <Button size="small" onClick={(e) => { e.stopPropagation(); handleOpenEdit(item); }} sx={{ minWidth: 'auto', mr: 0.5 }}>
</TableCell> <Edit fontSize="small" />
<TableCell align="center"> </Button>
{item.atemschutz_lehrgang ? ( </Tooltip>
<Tooltip title={item.lehrgang_datum ? `Lehrgang am ${formatDate(item.lehrgang_datum)}` : 'Lehrgang absolviert'}> <Tooltip title="Löschen">
<Check color="success" fontSize="small" /> <Button size="small" color="error" onClick={(e) => { e.stopPropagation(); setDeleteId(item.id); }} sx={{ minWidth: 'auto' }}>
</Tooltip> <Delete fontSize="small" />
) : ( </Button>
<Close color="disabled" fontSize="small" /> </Tooltip>
)} </>
</TableCell> ),
<TableCell> });
<Tooltip }
title={
item.untersuchung_tage_rest !== null return (
? item.untersuchung_tage_rest < 0 <DataTable
? `Seit ${Math.abs(item.untersuchung_tage_rest)} Tagen abgelaufen` columns={columns}
: `Noch ${item.untersuchung_tage_rest} Tage gültig` data={filtered}
: 'Keine Untersuchung eingetragen' rowKey={(item) => item.id}
} emptyMessage={traeger.length === 0 ? 'Keine Atemschutzträger vorhanden' : 'Keine Ergebnisse gefunden'}
> searchEnabled={false}
<Typography variant="body2" color={untersuchungColor} fontWeight={500}> paginationEnabled={false}
{formatDate(item.untersuchung_gueltig_bis)} />
</Typography> );
</Tooltip> })()}
</TableCell>
<TableCell>
<Tooltip
title={
item.leistungstest_tage_rest !== null
? item.leistungstest_tage_rest < 0
? `Seit ${Math.abs(item.leistungstest_tage_rest)} Tagen abgelaufen`
: `Noch ${item.leistungstest_tage_rest} Tage gültig`
: 'Kein Leistungstest eingetragen'
}
>
<Typography variant="body2" color={leistungstestColor} fontWeight={500}>
{formatDate(item.leistungstest_gueltig_bis)}
</Typography>
</Tooltip>
</TableCell>
<TableCell align="center">
<Chip
label={item.einsatzbereit ? 'Einsatzbereit' : 'Nicht einsatzbereit'}
color={item.einsatzbereit ? 'success' : 'error'}
size="small"
variant="filled"
/>
</TableCell>
{canWrite && (
<TableCell align="right">
<Tooltip title="Bearbeiten">
<Button
size="small"
onClick={() => handleOpenEdit(item)}
sx={{ minWidth: 'auto', mr: 0.5 }}
>
<Edit fontSize="small" />
</Button>
</Tooltip>
<Tooltip title="Löschen">
<Button
size="small"
color="error"
onClick={() => setDeleteId(item.id)}
sx={{ minWidth: 'auto' }}
>
<Delete fontSize="small" />
</Button>
</Tooltip>
</TableCell>
)}
</TableRow>
);
})}
</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,133 +742,89 @@ 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 <Chip
</Button> icon={STATUS_ICONS[equipment.status]}
label={AusruestungStatusLabel[equipment.status]}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 1 }}> color={STATUS_CHIP_COLOR[equipment.status]}
<Build sx={{ fontSize: 36, color: 'text.secondary' }} /> />
<Box> {canWrite && (
<Typography variant="h4" component="h1"> <Tooltip title="Gerät bearbeiten">
{equipment.bezeichnung} <IconButton
</Typography> size="small"
{subtitle && ( onClick={() => navigate(`/ausruestung/${equipment.id}/bearbeiten`)}
<Typography variant="subtitle1" color="text.secondary"> aria-label="Gerät bearbeiten"
{subtitle} >
</Typography> <Edit />
)} </IconButton>
</Box> </Tooltip>
<Box sx={{ ml: 'auto', display: 'flex', alignItems: 'center', gap: 1 }}> )}
<Chip {isAdmin && (
icon={STATUS_ICONS[equipment.status]} <Tooltip title="Gerät löschen">
label={AusruestungStatusLabel[equipment.status]} <IconButton
color={STATUS_CHIP_COLOR[equipment.status]} size="small"
/> color="error"
{canWrite && ( onClick={() => setDeleteDialogOpen(true)}
<Tooltip title="Gerät bearbeiten"> aria-label="Gerät löschen"
<IconButton >
size="small" <DeleteOutline />
onClick={() => navigate(`/ausruestung/${equipment.id}/bearbeiten`)} </IconButton>
aria-label="Gerät bearbeiten" </Tooltip>
> )}
<Edit /> </Box>
</IconButton> }
</Tooltip> />
)}
{isAdmin && (
<Tooltip title="Gerät löschen">
<IconButton
size="small"
color="error"
onClick={() => setDeleteDialogOpen(true)}
aria-label="Gerät löschen"
>
<DeleteOutline />
</IconButton>
</Tooltip>
)}
</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,40 +670,44 @@ 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>
<IconButton <IconButton
onClick={generateBestellungDetailPdf} onClick={generateBestellungDetailPdf}
color="primary" color="primary"
disabled={bestellung.status === 'entwurf' || bestellung.status === 'wartet_auf_genehmigung'} disabled={bestellung.status === 'entwurf' || bestellung.status === 'wartet_auf_genehmigung'}
> >
<PdfIcon /> <PdfIcon />
</IconButton> </IconButton>
</span> </span>
</Tooltip> </Tooltip>
)} )}
{canCreate && !editMode && ( {canCreate && !editMode && (
<Button startIcon={<EditIcon />} onClick={enterEditMode}>Bearbeiten</Button> <Button startIcon={<EditIcon />} onClick={enterEditMode}>Bearbeiten</Button>
)} )}
{editMode && ( {editMode && (
<> <>
<Button variant="contained" startIcon={<SaveIcon />} onClick={handleSaveAll} disabled={isSavingAll}> <Button variant="contained" startIcon={<SaveIcon />} onClick={handleSaveAll} disabled={isSavingAll}>
Speichern Speichern
</Button> </Button>
<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}
</Box> size="medium"
/>
</Box>
}
/>
{/* ── Info Cards ── */} {/* ── Info Cards ── */}
{editMode ? ( {editMode ? (
@@ -1344,73 +1344,68 @@ 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); }}
<Typography> onConfirm={() => statusConfirmTarget && updateStatus.mutate({ status: statusConfirmTarget, force: statusForce || undefined })}
Status von <strong>{BESTELLUNG_STATUS_LABELS[bestellung.status]}</strong> auf{' '} title={`Status ändern${statusForce ? ' (manuell)' : ''}`}
<strong>{statusConfirmTarget ? BESTELLUNG_STATUS_LABELS[statusConfirmTarget] : ''}</strong> ändern? message={
</Typography> <>
{statusForce && ( <Typography>
<Typography variant="body2" color="warning.main" sx={{ mt: 1 }}> Status von <strong>{BESTELLUNG_STATUS_LABELS[bestellung.status]}</strong> auf{' '}
Dies ist ein manueller Statusübergang außerhalb des normalen Ablaufs. <strong>{statusConfirmTarget ? BESTELLUNG_STATUS_LABELS[statusConfirmTarget] : ''}</strong> ändern?
</Typography> </Typography>
)} {statusForce && (
{statusConfirmTarget && ['lieferung_pruefen', 'abgeschlossen'].includes(statusConfirmTarget) && !allCostsEntered && ( <Typography variant="body2" color="warning.main" sx={{ mt: 1 }}>
<Alert severity="warning" sx={{ mt: 2 }}> Dies ist ein manueller Statusübergang außerhalb des normalen Ablaufs.
Nicht alle Positionen haben einen Einzelpreis. Bitte prüfen Sie die Kosten, bevor Sie den Status ändern. </Typography>
</Alert> )}
)} {statusConfirmTarget && ['lieferung_pruefen', 'abgeschlossen'].includes(statusConfirmTarget) && !allCostsEntered && (
</DialogContent> <Alert severity="warning" sx={{ mt: 2 }}>
<DialogActions> Nicht alle Positionen haben einen Einzelpreis. Bitte prüfen Sie die Kosten, bevor Sie den Status ändern.
<Button onClick={() => { setStatusConfirmTarget(null); setStatusForce(false); }}>Abbrechen</Button> </Alert>
<Button variant="contained" onClick={() => statusConfirmTarget && updateStatus.mutate({ status: statusConfirmTarget, force: statusForce || undefined })} disabled={updateStatus.isPending}> )}
Bestätigen </>
</Button> }
</DialogActions> confirmLabel="Bestätigen"
</Dialog> isLoading={updateStatus.isPending}
/>
{/* 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> const totalOrdered = o.total_ordered ?? 0;
<TableBody> const totalReceived = o.total_received ?? 0;
{ordersLoading ? ( const deliveryPct = totalOrdered > 0 ? (totalReceived / totalOrdered) * 100 : 0;
<TableRow><TableCell colSpan={9} align="center">Laden...</TableCell></TableRow> return totalOrdered > 0 ? (
) : filteredOrders.length === 0 ? ( <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 100 }}>
<TableRow><TableCell colSpan={9} align="center">Keine Bestellungen vorhanden</TableCell></TableRow> <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>
filteredOrders.map((o) => { </Box>
const brutto = calcBrutto(o); ) : '';
const totalOrdered = o.total_ordered ?? 0; }},
const totalReceived = o.total_received ?? 0; { key: 'erstellt_am', label: 'Erstellt am', render: (o) => formatDate(o.erstellt_am) },
const deliveryPct = totalOrdered > 0 ? (totalReceived / totalOrdered) * 100 : 0; ]}
return ( data={filteredOrders}
<TableRow rowKey={(o) => o.id}
key={o.id} onRowClick={(o) => navigate(`/bestellungen/${o.id}`)}
hover isLoading={ordersLoading}
sx={{ cursor: 'pointer' }} emptyMessage="Keine Bestellungen vorhanden"
onClick={() => navigate(`/bestellungen/${o.id}`)} searchEnabled={false}
> />
<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>
) : ''}
</TableCell>
<TableCell>{formatDate(o.erstellt_am)}</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</TableContainer>
{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> <a href={v.website.startsWith('http') ? v.website : `https://${v.website}`} target="_blank" rel="noopener noreferrer" onClick={(e) => e.stopPropagation()}>{v.website}</a>
<TableCell>Website</TableCell> ) : '' },
</TableRow> ]}
</TableHead> data={vendors}
<TableBody> rowKey={(v) => v.id}
{vendorsLoading ? ( onRowClick={(v) => navigate(`/bestellungen/lieferanten/${v.id}`)}
<TableRow><TableCell colSpan={5} align="center">Laden...</TableCell></TableRow> isLoading={vendorsLoading}
) : vendors.length === 0 ? ( emptyMessage="Keine Lieferanten vorhanden"
<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>
) : ''}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</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,114 +390,62 @@ 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"
count={total} count={total}
page={page} page={page}
onPageChange={handlePageChange} onPageChange={handlePageChange}
rowsPerPage={rowsPerPage} rowsPerPage={rowsPerPage}
onRowsPerPageChange={handleRowsPerPageChange} onRowsPerPageChange={handleRowsPerPageChange}
rowsPerPageOptions={[10, 25, 50, 100]} rowsPerPageOptions={[10, 25, 50, 100]}
labelRowsPerPage="Einträge pro Seite:" labelRowsPerPage="Einträge pro Seite:"
labelDisplayedRows={({ from, to, count }) => labelDisplayedRows={({ from, to, count }) =>
`${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,94 +280,75 @@ 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]}
<Tooltip title="PDF exportieren (Vorschau)"> sx={{ fontWeight: 600 }}
<Button />
<Chip
label={EINSATZ_STATUS_LABELS[einsatz.status]}
color={STATUS_CHIP_COLOR[einsatz.status]}
variant="outlined" variant="outlined"
startIcon={<PictureAsPdf />}
onClick={handleExportPdf}
size="small" size="small"
> />
PDF Export <Tooltip title="PDF exportieren (Vorschau)">
</Button>
</Tooltip>
{canWrite && !editing ? (
<Button
variant="contained"
startIcon={<Edit />}
onClick={() => setEditing(true)}
size="small"
>
Bearbeiten
</Button>
) : canWrite && editing ? (
<>
<Button <Button
variant="outlined" variant="outlined"
startIcon={<Cancel />} startIcon={<PictureAsPdf />}
onClick={handleCancelEdit} onClick={handleExportPdf}
size="small" size="small"
disabled={saving}
> >
Abbrechen PDF Export
</Button> </Button>
</Tooltip>
{canWrite && !editing ? (
<Button <Button
variant="contained" variant="contained"
color="success" startIcon={<Edit />}
startIcon={<Save />} onClick={() => setEditing(true)}
onClick={handleSaveBericht}
size="small" size="small"
disabled={saving}
> >
{saving ? 'Speichere...' : 'Speichern'} Bearbeiten
</Button> </Button>
</> ) : canWrite && editing ? (
) : null} <>
</Stack> <Button
</Box> variant="outlined"
startIcon={<Cancel />}
onClick={handleCancelEdit}
size="small"
disabled={saving}
>
Abbrechen
</Button>
<Button
variant="contained"
color="success"
startIcon={<Save />}
onClick={handleSaveBericht}
size="small"
disabled={saving}
>
{saving ? 'Speichere...' : 'Speichern'}
</Button>
</>
) : null}
</Stack>
}
/>
{/* HEADER */} {einsatz.einsatz_stichwort && (
<Box sx={{ mb: 3 }}> <Typography variant="h6" color="text.secondary" sx={{ mb: 2, mt: -2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flexWrap: 'wrap', mb: 1 }}> {einsatz.einsatz_stichwort}
<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 && (
<Typography variant="h6" color="text.secondary">
{einsatz.einsatz_stichwort}
</Typography>
)}
</Box>
<Typography variant="h4" fontWeight={700}>
Einsatz {einsatz.einsatz_nr}
</Typography> </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,130 +941,105 @@ 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 <Chip
</Button> icon={STATUS_ICONS[vehicle.status]}
label={FahrzeugStatusLabel[vehicle.status]}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 1 }}> color={STATUS_CHIP_COLOR[vehicle.status]}
<DirectionsCar sx={{ fontSize: 36, color: 'text.secondary' }} /> />
<Box> {isAdmin && (
<Typography variant="h4" component="h1"> <Tooltip title="Fahrzeug bearbeiten">
{vehicle.bezeichnung} <IconButton
{vehicle.kurzname && ( size="small"
<Typography component="span" variant="h5" color="text.secondary" sx={{ ml: 1 }}> onClick={() => navigate(`/fahrzeuge/${vehicle.id}/bearbeiten`)}
{vehicle.kurzname} aria-label="Fahrzeug bearbeiten"
</Typography> >
<Edit />
</IconButton>
</Tooltip>
)} )}
</Typography> {isAdmin && (
{vehicle.amtliches_kennzeichen && ( <Tooltip title="Fahrzeug löschen">
<Typography variant="subtitle1" color="text.secondary"> <IconButton
{vehicle.amtliches_kennzeichen} size="small"
</Typography> color="error"
)} onClick={() => setDeleteDialogOpen(true)}
</Box> aria-label="Fahrzeug löschen"
<Box sx={{ ml: 'auto', display: 'flex', alignItems: 'center', gap: 1 }}> >
<Chip <DeleteOutline />
icon={STATUS_ICONS[vehicle.status]} </IconButton>
label={FahrzeugStatusLabel[vehicle.status]} </Tooltip>
color={STATUS_CHIP_COLOR[vehicle.status]} )}
/> </Box>
{isAdmin && ( }
<Tooltip title="Fahrzeug bearbeiten"> />
<IconButton
size="small"
onClick={() => navigate(`/fahrzeuge/${vehicle.id}/bearbeiten`)}
aria-label="Fahrzeug bearbeiten"
>
<Edit />
</IconButton>
</Tooltip>
)}
{isAdmin && (
<Tooltip title="Fahrzeug löschen">
<IconButton
size="small"
color="error"
onClick={() => setDeleteDialogOpen(true)}
aria-label="Fahrzeug löschen"
>
<DeleteOutline />
</IconButton>
</Tooltip>
)}
</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> <Chip
<Box sx={{ flex: 1 }}> label={getStatusLabel(statuses, issue.status)}
<Typography variant="h5"> color={getStatusColor(statuses, issue.status)}
{formatIssueId(issue)} {issue.titel} />
</Typography> }
</Box> />
<Chip
label={getStatusLabel(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,50 +555,38 @@ 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)}
<TextField onSubmit={handleReopen}
label="Kommentar (Pflicht)" title="Issue wiedereröffnen"
required submitLabel="Wiedereröffnen"
multiline isSubmitting={updateMut.isPending}
rows={3} maxWidth="sm"
fullWidth >
value={reopenComment} <TextField
onChange={(e) => setReopenComment(e.target.value)} label="Kommentar (Pflicht)"
autoFocus required
/> multiline
</DialogContent> rows={3}
<DialogActions> fullWidth
<Button onClick={() => setReopenOpen(false)}>Abbrechen</Button> value={reopenComment}
<Button onChange={(e) => setReopenComment(e.target.value)}
variant="contained" autoFocus
disabled={!reopenComment.trim() || updateMut.isPending} />
onClick={handleReopen} </FormDialog>
>
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,99 +53,98 @@ 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
label="Titel"
required
fullWidth
value={form.titel}
onChange={(e) => setForm({ ...form, titel: e.target.value })}
autoFocus
/>
<Collapse in={showDescription} unmountOnExit>
<TextField <TextField
label="Titel" label="Beschreibung"
required multiline
rows={4}
fullWidth fullWidth
value={form.titel} value={form.beschreibung || ''}
onChange={(e) => setForm({ ...form, titel: e.target.value })} onChange={(e) => setForm({ ...form, beschreibung: e.target.value })}
autoFocus
/> />
</Collapse>
{!showDescription && (
<Button
size="small"
startIcon={<AddIcon />}
onClick={() => setShowDescription(true)}
sx={{ alignSelf: 'flex-start' }}
>
Beschreibung hinzufuegen
</Button>
)}
<Collapse in={showDescription} unmountOnExit> <Grid container spacing={2}>
<TextField <Grid item xs={12} sm={6}>
label="Beschreibung" <FormControl fullWidth>
multiline <InputLabel>Typ</InputLabel>
rows={4} <Select
fullWidth value={form.typ_id ?? defaultTypId ?? ''}
value={form.beschreibung || ''} label="Typ"
onChange={(e) => setForm({ ...form, beschreibung: e.target.value })} onChange={(e) => setForm({ ...form, typ_id: Number(e.target.value) })}
/> >
</Collapse> {types.filter(t => t.aktiv).map(t => (
{!showDescription && ( <MenuItem key={t.id} value={t.id}>{t.name}</MenuItem>
<Button ))}
size="small" </Select>
startIcon={<AddIcon />} </FormControl>
onClick={() => setShowDescription(true)}
sx={{ alignSelf: 'flex-start' }}
>
Beschreibung hinzufuegen
</Button>
)}
<Grid container spacing={2}>
<Grid item xs={12} sm={6}>
<FormControl fullWidth>
<InputLabel>Typ</InputLabel>
<Select
value={form.typ_id ?? defaultTypId ?? ''}
label="Typ"
onChange={(e) => setForm({ ...form, typ_id: Number(e.target.value) })}
>
{types.filter(t => t.aktiv).map(t => (
<MenuItem key={t.id} value={t.id}>{t.name}</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid item xs={12} sm={6}>
<FormControl fullWidth>
<InputLabel>Prioritaet</InputLabel>
<Select
value={form.prioritaet || defaultPriority}
label="Prioritaet"
onChange={(e) => setForm({ ...form, prioritaet: e.target.value })}
>
{priorities.filter(p => p.aktiv).map(p => (
<MenuItem key={p.schluessel} value={p.schluessel}>{p.bezeichnung}</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
label="Fällig am"
type="date"
fullWidth
value={form.faellig_am || ''}
onChange={(e) => setForm({ ...form, faellig_am: e.target.value || null })}
InputLabelProps={{ shrink: true }}
/>
</Grid>
</Grid> </Grid>
<Grid item xs={12} sm={6}>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1, mt: 1 }}> <FormControl fullWidth>
<Button onClick={() => navigate('/issues')}>Abbrechen</Button> <InputLabel>Prioritaet</InputLabel>
<Button <Select
variant="contained" value={form.prioritaet || defaultPriority}
disabled={!form.titel.trim() || createMut.isPending} label="Prioritaet"
onClick={handleSubmit} onChange={(e) => setForm({ ...form, prioritaet: e.target.value })}
> >
Erstellen {priorities.filter(p => p.aktiv).map(p => (
</Button> <MenuItem key={p.schluessel} value={p.schluessel}>{p.bezeichnung}</MenuItem>
</Box> ))}
</Box> </Select>
</Paper> </FormControl>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
label="Fällig am"
type="date"
fullWidth
value={form.faellig_am || ''}
onChange={(e) => setForm({ ...form, faellig_am: e.target.value || null })}
InputLabelProps={{ shrink: true }}
/>
</Grid>
</Grid>
</FormLayout>
</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} /> <Paper sx={{ p: 3, mt: 2 }}>
</Box>
<Paper sx={{ p: 3 }}>
<Skeleton height={40} /> <Skeleton height={40} />
<Skeleton height={40} /> <Skeleton height={40} />
<Skeleton height={40} /> <Skeleton height={40} />
@@ -169,39 +160,44 @@ 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> ]}
{!isNew && canManage && !editMode && ( backTo="/bestellungen?tab=1"
actions={
<> <>
<Button startIcon={<EditIcon />} onClick={() => setEditMode(true)}> {!isNew && canManage && !editMode && (
Bearbeiten <>
</Button> <Button startIcon={<EditIcon />} onClick={() => setEditMode(true)}>
<Button startIcon={<DeleteIcon />} color="error" onClick={() => setDeleteDialogOpen(true)}> Bearbeiten
Löschen </Button>
</Button> <Button startIcon={<DeleteIcon />} color="error" onClick={() => setDeleteDialogOpen(true)}>
Löschen
</Button>
</>
)}
{editMode && (
<>
<Button
variant="contained"
startIcon={<SaveIcon />}
onClick={handleSave}
disabled={!form.name.trim() || isSaving}
>
Speichern
</Button>
<Button startIcon={<CloseIcon />} onClick={handleCancel}>
Abbrechen
</Button>
</>
)}
</> </>
)} }
{editMode && ( />
<>
<Button
variant="contained"
startIcon={<SaveIcon />}
onClick={handleSave}
disabled={!form.name.trim() || isSaving}
>
Speichern
</Button>
<Button startIcon={<CloseIcon />} onClick={handleCancel}>
Abbrechen
</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> const displayName = getMemberDisplayName(member);
<TableCell sx={{ width: 56 }}>Foto</TableCell> const initials = [member.given_name?.[0], member.family_name?.[0]]
<TableCell>Name</TableCell> .filter(Boolean).join('').toUpperCase() || member.email[0].toUpperCase();
<TableCell>Stundenbuchnr.</TableCell> return (
<TableCell>Dienstgrad</TableCell> <Avatar src={member.profile_picture_url ?? undefined} alt={displayName} sx={{ width: 36, height: 36, fontSize: '0.875rem' }}>
<TableCell>Funktion</TableCell> {initials}
<TableCell>Status</TableCell> </Avatar>
<TableCell>Eintrittsdatum</TableCell> );
<TableCell>Telefon</TableCell> }},
</TableRow> { key: 'family_name', label: 'Name', render: (member) => {
</TableHead> const displayName = getMemberDisplayName(member);
<TableBody> return (
{loading ? ( <Box>
<TableRow> <Typography variant="body2" fontWeight={500}>{displayName}</Typography>
<TableCell colSpan={8} align="center" sx={{ py: 6 }}> <Typography variant="caption" color="text.secondary">{member.email}</Typography>
<CircularProgress size={32} /> </Box>
</TableCell> );
</TableRow> }},
) : members.length === 0 ? ( { key: 'fdisk_standesbuch_nr', label: 'Stundenbuchnr.', render: (member) => member.fdisk_standesbuch_nr ?? '—' },
<TableRow> { key: 'dienstgrad', label: 'Dienstgrad', render: (member) => member.dienstgrad
<TableCell colSpan={8} align="center" sx={{ py: 8 }}> ? <Chip label={member.dienstgrad} size="small" variant="outlined" />
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1 }}> : <Typography variant="body2" color="text.secondary"></Typography>
<PeopleIcon sx={{ fontSize: 48, color: 'text.disabled' }} /> },
<Typography color="text.secondary"> { key: 'funktion', label: 'Funktion', sortable: false, render: (member) => (
Keine Mitglieder gefunden. <Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
</Typography> {Array.isArray(member.funktion) && member.funktion.length > 0
</Box> ? member.funktion.map((f) => <Chip key={f} label={f} size="small" variant="outlined" color="secondary" />)
</TableCell> : <Typography variant="body2" color="text.secondary"></Typography>
</TableRow> }
) : ( </Box>
members.map((member) => { )},
const displayName = getMemberDisplayName(member); { key: 'status', label: 'Status', render: (member) => member.status
const initials = [member.given_name?.[0], member.family_name?.[0]] ? <StatusChip status={member.status} labelMap={STATUS_LABELS} colorMap={STATUS_COLORS} />
.filter(Boolean) : <Typography variant="body2" color="text.secondary"></Typography>
.join('') },
.toUpperCase() || member.email[0].toUpperCase(); { key: 'eintrittsdatum', label: 'Eintrittsdatum', render: (member) => member.eintrittsdatum
? new Date(member.eintrittsdatum).toLocaleDateString('de-AT')
return ( : '—'
<TableRow },
key={member.id} { key: 'telefon_mobil', label: 'Telefon', render: (member) => formatPhone(member.telefon_mobil) },
hover ]}
onClick={() => handleRowClick(member.id)} data={members}
sx={{ cursor: 'pointer' }} rowKey={(member) => member.id}
aria-label={`Mitglied ${displayName} öffnen`} onRowClick={(member) => handleRowClick(member.id)}
> isLoading={loading}
{/* Avatar */} emptyMessage="Keine Mitglieder gefunden."
<TableCell> emptyIcon={<PeopleIcon sx={{ fontSize: 40, mb: 1, opacity: 0.5 }} />}
<Avatar searchEnabled={false}
src={member.profile_picture_url ?? undefined} paginationEnabled={false}
alt={displayName} stickyHeader
sx={{ width: 36, height: 36, fontSize: '0.875rem' }} />
>
{initials}
</Avatar>
</TableCell>
{/* Name + email */}
<TableCell>
<Typography variant="body2" fontWeight={500}>
{displayName}
</Typography>
<Typography variant="caption" color="text.secondary">
{member.email}
</Typography>
</TableCell>
{/* Stundenbuchnr */}
<TableCell>
<Typography variant="body2">
{member.fdisk_standesbuch_nr ?? '—'}
</Typography>
</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' }}>
{Array.isArray(member.funktion) && member.funktion.length > 0
? member.funktion.map((f) => (
<Chip key={f} label={f} size="small" variant="outlined" color="secondary" />
))
: <Typography variant="body2" color="text.secondary"></Typography>
}
</Box>
</TableCell>
{/* Status */}
<TableCell>
{member.status ? (
<Chip
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')
: '—'}
</Typography>
</TableCell>
{/* Telefon */}
<TableCell>
<Typography variant="body2">
{formatPhone(member.telefon_mobil)}
</Typography>
</TableCell>
</TableRow>
);
})
)}
</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,
},
},
},
}, },
}; };