feat(frontend): implement unified design system with 17 reusable template components, skeleton loading states, and golden-ratio-based layouts
This commit is contained in:
@@ -1,13 +1,13 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import {
|
||||
Box, Paper, Typography, TextField, Button, Alert,
|
||||
Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions,
|
||||
CircularProgress, Divider,
|
||||
} from '@mui/material';
|
||||
import DeleteSweepIcon from '@mui/icons-material/DeleteSweep';
|
||||
import RestartAltIcon from '@mui/icons-material/RestartAlt';
|
||||
import { api } from '../../services/api';
|
||||
import { useNotification } from '../../contexts/NotificationContext';
|
||||
import { ConfirmDialog } from '../templates';
|
||||
|
||||
interface CleanupSection {
|
||||
key: string;
|
||||
@@ -193,31 +193,21 @@ export default function DataManagementTab() {
|
||||
);
|
||||
})}
|
||||
|
||||
<Dialog open={!!confirmDialog} onClose={() => !deleting && setConfirmDialog(null)}>
|
||||
<DialogTitle>Daten loeschen?</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
{confirmDialog && (
|
||||
<ConfirmDialog
|
||||
open={!!confirmDialog}
|
||||
onClose={() => setConfirmDialog(null)}
|
||||
onConfirm={handleDelete}
|
||||
title="Daten loeschen?"
|
||||
message={confirmDialog ? (
|
||||
<>
|
||||
<strong>{confirmDialog.count}</strong> {confirmDialog.count === 1 ? 'Eintrag' : 'Eintraege'} aus <strong>{confirmDialog.label}</strong> werden
|
||||
unwiderruflich geloescht. Dieser Vorgang kann nicht rueckgaengig gemacht werden.
|
||||
</>
|
||||
)}
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setConfirmDialog(null)} disabled={deleting}>Abbrechen</Button>
|
||||
<Button
|
||||
onClick={handleDelete}
|
||||
color="error"
|
||||
variant="contained"
|
||||
disabled={deleting}
|
||||
startIcon={deleting ? <CircularProgress size={16} /> : <DeleteSweepIcon />}
|
||||
>
|
||||
{deleting ? 'Wird geloescht...' : 'Endgueltig loeschen'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
) : ''}
|
||||
confirmLabel="Endgueltig loeschen"
|
||||
confirmColor="error"
|
||||
isLoading={deleting}
|
||||
/>
|
||||
|
||||
{/* ---- Reset / Truncate sections ---- */}
|
||||
<Divider sx={{ my: 4 }} />
|
||||
@@ -268,31 +258,21 @@ export default function DataManagementTab() {
|
||||
);
|
||||
})}
|
||||
|
||||
<Dialog open={!!resetConfirmDialog} onClose={() => !resetDeleting && setResetConfirmDialog(null)}>
|
||||
<DialogTitle>Daten zuruecksetzen?</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
{resetConfirmDialog && (
|
||||
<ConfirmDialog
|
||||
open={!!resetConfirmDialog}
|
||||
onClose={() => setResetConfirmDialog(null)}
|
||||
onConfirm={handleResetDelete}
|
||||
title="Daten zuruecksetzen?"
|
||||
message={resetConfirmDialog ? (
|
||||
<>
|
||||
<strong>{resetConfirmDialog.count}</strong> {resetConfirmDialog.count === 1 ? 'Eintrag' : 'Eintraege'} aus <strong>{resetConfirmDialog.label}</strong> werden
|
||||
unwiderruflich geloescht und die Nummerierung auf 1 zurueckgesetzt. Dieser Vorgang kann nicht rueckgaengig gemacht werden.
|
||||
</>
|
||||
)}
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setResetConfirmDialog(null)} disabled={resetDeleting}>Abbrechen</Button>
|
||||
<Button
|
||||
onClick={handleResetDelete}
|
||||
color="error"
|
||||
variant="contained"
|
||||
disabled={resetDeleting}
|
||||
startIcon={resetDeleting ? <CircularProgress size={16} /> : <RestartAltIcon />}
|
||||
>
|
||||
{resetDeleting ? 'Wird zurueckgesetzt...' : 'Endgueltig zuruecksetzen'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
) : ''}
|
||||
confirmLabel="Endgueltig zuruecksetzen"
|
||||
confirmColor="error"
|
||||
isLoading={resetDeleting}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Card, CardContent, Typography, Box, Chip } from '@mui/material';
|
||||
import { Typography, Box, Chip } from '@mui/material';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { MonitorHeartOutlined } from '@mui/icons-material';
|
||||
import { adminApi } from '../../services/admin';
|
||||
import { useCountUp } from '../../hooks/useCountUp';
|
||||
import { usePermissionContext } from '../../contexts/PermissionContext';
|
||||
import { WidgetCard } from '../templates/WidgetCard';
|
||||
import { StatSkeleton } from '../templates/SkeletonPresets';
|
||||
|
||||
function AdminStatusWidget() {
|
||||
const { hasPermission } = usePermissionContext();
|
||||
@@ -29,18 +31,13 @@ function AdminStatusWidget() {
|
||||
const color = allUp ? 'success' : majorityDown ? 'error' : 'warning';
|
||||
|
||||
return (
|
||||
<Card
|
||||
sx={{ cursor: 'pointer', '&:hover': { boxShadow: 4 } }}
|
||||
<WidgetCard
|
||||
title="Service Status"
|
||||
icon={<MonitorHeartOutlined color={color} />}
|
||||
isLoading={!data}
|
||||
skeleton={<StatSkeleton />}
|
||||
onClick={() => navigate('/admin')}
|
||||
>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||
<MonitorHeartOutlined color={color} />
|
||||
<Typography variant="h6" component="div">
|
||||
Service Status
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.5, mb: 1 }}>
|
||||
<Typography variant="h3" component="span" sx={{ fontWeight: 700 }}>
|
||||
{up}
|
||||
@@ -59,8 +56,7 @@ function AdminStatusWidget() {
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</WidgetCard>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Card, CardContent, Typography, Box, Chip, Skeleton } from '@mui/material';
|
||||
import { Box, Chip } from '@mui/material';
|
||||
import { Build } from '@mui/icons-material';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ausruestungsanfrageApi } from '../../services/ausruestungsanfrage';
|
||||
import type { AusruestungWidgetOverview } from '../../types/ausruestungsanfrage.types';
|
||||
import { WidgetCard } from '../templates/WidgetCard';
|
||||
import { ChipListSkeleton } from '../templates/SkeletonPresets';
|
||||
|
||||
function AusruestungsanfrageWidget() {
|
||||
const navigate = useNavigate();
|
||||
@@ -15,59 +17,32 @@ function AusruestungsanfrageWidget() {
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>Interne Bestellungen</Typography>
|
||||
<Skeleton variant="rectangular" height={60} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !overview) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>Interne Bestellungen</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Daten konnten nicht geladen werden.
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const hasAny = overview.total_count > 0;
|
||||
const hasAny = (overview?.total_count ?? 0) > 0;
|
||||
|
||||
return (
|
||||
<Card
|
||||
sx={{ cursor: 'pointer', '&:hover': { bgcolor: 'action.hover' } }}
|
||||
<WidgetCard
|
||||
title="Interne Bestellungen"
|
||||
icon={<Build fontSize="small" color="action" />}
|
||||
isLoading={isLoading}
|
||||
skeleton={<ChipListSkeleton />}
|
||||
isError={isError || (!isLoading && !overview)}
|
||||
errorMessage="Daten konnten nicht geladen werden."
|
||||
isEmpty={!isLoading && !isError && !!overview && !hasAny}
|
||||
emptyMessage="Keine Anfragen vorhanden."
|
||||
onClick={() => navigate('/ausruestungsanfrage')}
|
||||
>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1.5 }}>
|
||||
<Typography variant="h6">Interne Bestellungen</Typography>
|
||||
<Build fontSize="small" color="action" />
|
||||
</Box>
|
||||
{!hasAny ? (
|
||||
<Typography variant="body2" color="text.secondary">Keine Anfragen vorhanden.</Typography>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||
{overview.unhandled_count > 0 && (
|
||||
{overview && overview.unhandled_count > 0 && (
|
||||
<Chip label={`${overview.unhandled_count} Neu`} size="small" color="error" variant="outlined" />
|
||||
)}
|
||||
{overview.pending_count > 0 && (
|
||||
{overview && overview.pending_count > 0 && (
|
||||
<Chip label={`${overview.pending_count} Offen`} size="small" color="warning" variant="outlined" />
|
||||
)}
|
||||
{overview.approved_count > 0 && (
|
||||
{overview && overview.approved_count > 0 && (
|
||||
<Chip label={`${overview.approved_count} Genehmigt`} size="small" color="info" variant="outlined" />
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</WidgetCard>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Card, CardContent, Typography, Box } from '@mui/material';
|
||||
import { Typography, Box } from '@mui/material';
|
||||
import { Campaign } from '@mui/icons-material';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { bannerApi } from '../../services/banners';
|
||||
import type { BannerLevel } from '../../types/banner.types';
|
||||
import { WidgetCard } from '../templates/WidgetCard';
|
||||
|
||||
const SEVERITY_COLOR: Record<BannerLevel, string> = {
|
||||
info: '#1976d2',
|
||||
@@ -23,12 +24,10 @@ export default function BannerWidget() {
|
||||
if (widgetBanners.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<Campaign color="primary" />
|
||||
<Typography variant="h6">Mitteilungen</Typography>
|
||||
</Box>
|
||||
<WidgetCard
|
||||
title="Mitteilungen"
|
||||
icon={<Campaign color="primary" />}
|
||||
>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
{widgetBanners.map(banner => (
|
||||
<Box
|
||||
@@ -45,7 +44,6 @@ export default function BannerWidget() {
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</WidgetCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Card, CardContent, Typography, Box, Chip, Skeleton } from '@mui/material';
|
||||
import { Box, Chip } from '@mui/material';
|
||||
import { LocalShipping } from '@mui/icons-material';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { bestellungApi } from '../../services/bestellung';
|
||||
import { BESTELLUNG_STATUS_LABELS, BESTELLUNG_STATUS_COLORS } from '../../types/bestellung.types';
|
||||
import type { BestellungStatus } from '../../types/bestellung.types';
|
||||
import { WidgetCard } from '../templates/WidgetCard';
|
||||
import { ChipListSkeleton } from '../templates/SkeletonPresets';
|
||||
|
||||
function BestellungenWidget() {
|
||||
const navigate = useNavigate();
|
||||
@@ -26,40 +28,18 @@ function BestellungenWidget() {
|
||||
.map((s) => ({ status: s, count: openOrders.filter((o) => o.status === s).length }))
|
||||
.filter((s) => s.count > 0);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>Bestellungen</Typography>
|
||||
<Skeleton variant="rectangular" height={40} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>Bestellungen</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Bestellungen konnten nicht geladen werden.
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card sx={{ cursor: 'pointer', '&:hover': { bgcolor: 'action.hover' } }} onClick={() => navigate('/bestellungen')}>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1.5 }}>
|
||||
<Typography variant="h6">Bestellungen</Typography>
|
||||
<LocalShipping fontSize="small" color="action" />
|
||||
</Box>
|
||||
{statusCounts.length === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary">Keine offenen Bestellungen</Typography>
|
||||
) : (
|
||||
<WidgetCard
|
||||
title="Bestellungen"
|
||||
icon={<LocalShipping fontSize="small" color="action" />}
|
||||
isLoading={isLoading}
|
||||
skeleton={<ChipListSkeleton />}
|
||||
isError={isError}
|
||||
errorMessage="Bestellungen konnten nicht geladen werden."
|
||||
isEmpty={!isLoading && !isError && statusCounts.length === 0}
|
||||
emptyMessage="Keine offenen Bestellungen"
|
||||
onClick={() => navigate('/bestellungen')}
|
||||
>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{statusCounts.map(({ status, count }) => (
|
||||
<Chip
|
||||
@@ -71,9 +51,7 @@ function BestellungenWidget() {
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</WidgetCard>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Box,
|
||||
Divider,
|
||||
Skeleton,
|
||||
} from '@mui/material';
|
||||
import { MenuBook } from '@mui/icons-material';
|
||||
@@ -14,11 +11,10 @@ import { de } from 'date-fns/locale';
|
||||
import { bookstackApi } from '../../services/bookstack';
|
||||
import type { BookStackPage } from '../../types/bookstack.types';
|
||||
import { safeOpenUrl } from '../../utils/safeOpenUrl';
|
||||
import { ListCard } from '../templates/ListCard';
|
||||
import { WidgetCard } from '../templates/WidgetCard';
|
||||
|
||||
const PageRow: React.FC<{ page: BookStackPage; showDivider: boolean }> = ({
|
||||
page,
|
||||
showDivider,
|
||||
}) => {
|
||||
const PageRow: React.FC<{ page: BookStackPage }> = ({ page }) => {
|
||||
const handleClick = () => {
|
||||
safeOpenUrl(page.url);
|
||||
};
|
||||
@@ -28,14 +24,12 @@ const PageRow: React.FC<{ page: BookStackPage; showDivider: boolean }> = ({
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
onClick={handleClick}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'space-between',
|
||||
py: 1.5,
|
||||
px: 1,
|
||||
cursor: 'pointer',
|
||||
borderRadius: 1,
|
||||
@@ -63,8 +57,6 @@ const PageRow: React.FC<{ page: BookStackPage; showDivider: boolean }> = ({
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
{showDivider && <Divider />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -80,77 +72,44 @@ const BookStackRecentWidget: React.FC = () => {
|
||||
const pages = (data?.data ?? []).slice(0, 5);
|
||||
|
||||
// Only show "nicht eingerichtet" when we got a successful response with configured=false
|
||||
// (not when the request errored out with 403 etc.)
|
||||
if (data && !configured) {
|
||||
return (
|
||||
<Card sx={{ height: '100%' }}>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<MenuBook color="disabled" />
|
||||
<Typography variant="h6" sx={{ flexGrow: 1 }} color="text.secondary">
|
||||
Wissen — Neueste Seiten
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ py: 2, textAlign: 'center' }}>
|
||||
BookStack nicht eingerichtet
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<WidgetCard
|
||||
title="Wissen — Neueste Seiten"
|
||||
icon={<MenuBook color="disabled" />}
|
||||
isEmpty
|
||||
emptyMessage="BookStack nicht eingerichtet"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<WidgetCard
|
||||
title="Wissen — Neueste Seiten"
|
||||
icon={<MenuBook color="primary" />}
|
||||
isError
|
||||
errorMessage="BookStack nicht erreichbar"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
height: '100%',
|
||||
transition: 'all 0.3s ease',
|
||||
'&:hover': { boxShadow: 3 },
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<MenuBook color="primary" />
|
||||
<Typography variant="h6" sx={{ flexGrow: 1 }}>
|
||||
Wissen — Neueste Seiten
|
||||
</Typography>
|
||||
<ListCard
|
||||
title="Wissen — Neueste Seiten"
|
||||
icon={<MenuBook color="primary" />}
|
||||
items={pages}
|
||||
renderItem={(page) => <PageRow key={page.id} page={page} />}
|
||||
isLoading={isLoading}
|
||||
skeletonCount={5}
|
||||
skeletonItem={
|
||||
<Box sx={{ mb: 0.5 }}>
|
||||
<Skeleton variant="text" width="70%" height={22} animation="wave" />
|
||||
<Skeleton variant="text" width="50%" height={18} animation="wave" />
|
||||
</Box>
|
||||
|
||||
{isLoading && (
|
||||
<Box>
|
||||
{[1, 2, 3, 4, 5].map((n) => (
|
||||
<Box key={n} sx={{ mb: 1.5 }}>
|
||||
<Skeleton variant="text" width="70%" height={22} />
|
||||
<Skeleton variant="text" width="50%" height={18} />
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{isError && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ py: 2, textAlign: 'center' }}>
|
||||
BookStack nicht erreichbar
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{!isLoading && !isError && pages.length === 0 && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ py: 2, textAlign: 'center' }}>
|
||||
Keine Seiten gefunden
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{!isLoading && !isError && pages.length > 0 && (
|
||||
<Box>
|
||||
{pages.map((page, index) => (
|
||||
<PageRow
|
||||
key={page.id}
|
||||
page={page}
|
||||
showDivider={index < pages.length - 1}
|
||||
}
|
||||
emptyMessage="Keine Seiten gefunden"
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Box,
|
||||
TextField,
|
||||
Divider,
|
||||
CircularProgress,
|
||||
InputAdornment,
|
||||
Skeleton,
|
||||
@@ -15,6 +12,7 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { bookstackApi } from '../../services/bookstack';
|
||||
import type { BookStackSearchResult } from '../../types/bookstack.types';
|
||||
import { safeOpenUrl } from '../../utils/safeOpenUrl';
|
||||
import { WidgetCard } from '../templates/WidgetCard';
|
||||
|
||||
function stripHtml(html: string): string {
|
||||
return html.replace(/<[^>]*>/g, '').trim();
|
||||
@@ -22,7 +20,7 @@ function stripHtml(html: string): string {
|
||||
|
||||
const ResultRow: React.FC<{ result: BookStackSearchResult; showDivider: boolean }> = ({
|
||||
result,
|
||||
showDivider,
|
||||
showDivider: _showDivider,
|
||||
}) => {
|
||||
const preview = result.preview_html?.content ? stripHtml(result.preview_html.content) : '';
|
||||
|
||||
@@ -58,7 +56,6 @@ const ResultRow: React.FC<{ result: BookStackSearchResult; showDivider: boolean
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
{showDivider && <Divider />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -123,52 +120,31 @@ const BookStackSearchWidget: React.FC = () => {
|
||||
|
||||
if (configured === undefined) {
|
||||
return (
|
||||
<Card sx={{ height: '100%' }}>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<MenuBook color="primary" />
|
||||
<Skeleton variant="text" width={160} height={32} />
|
||||
</Box>
|
||||
<Skeleton variant="rectangular" height={40} sx={{ borderRadius: 1 }} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<WidgetCard
|
||||
title="Wissen — Suche"
|
||||
icon={<MenuBook color="primary" />}
|
||||
isLoading
|
||||
skeleton={<Skeleton variant="rectangular" height={40} sx={{ borderRadius: 1 }} animation="wave" />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (configured === false) {
|
||||
return (
|
||||
<Card sx={{ height: '100%' }}>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<MenuBook color="disabled" />
|
||||
<Typography variant="h6" sx={{ flexGrow: 1 }} color="text.secondary">
|
||||
Wissen — Suche
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ py: 2, textAlign: 'center' }}>
|
||||
BookStack nicht eingerichtet
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<WidgetCard
|
||||
title="Wissen — Suche"
|
||||
icon={<MenuBook color="disabled" />}
|
||||
isEmpty
|
||||
emptyMessage="BookStack nicht eingerichtet"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
height: '100%',
|
||||
transition: 'all 0.3s ease',
|
||||
'&:hover': { boxShadow: 3 },
|
||||
}}
|
||||
<WidgetCard
|
||||
title="Wissen — Suche"
|
||||
icon={<MenuBook color="primary" />}
|
||||
>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<MenuBook color="primary" />
|
||||
<Typography variant="h6" sx={{ flexGrow: 1 }}>
|
||||
Wissen — Suche
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
@@ -186,7 +162,7 @@ const BookStackSearchWidget: React.FC = () => {
|
||||
|
||||
{!searching && query.trim() && results.length === 0 && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ py: 2, textAlign: 'center' }}>
|
||||
Keine Ergebnisse für „{query}"
|
||||
Keine Ergebnisse für \u201e{query}\u201c
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
@@ -201,8 +177,7 @@ const BookStackSearchWidget: React.FC = () => {
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</WidgetCard>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Card, CardContent, Typography, Box, Chip, Skeleton } from '@mui/material';
|
||||
import { Typography, Box, Chip } from '@mui/material';
|
||||
import { AccountBalance } from '@mui/icons-material';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { buchhaltungApi } from '../../services/buchhaltung';
|
||||
import { WidgetCard } from '../templates/WidgetCard';
|
||||
import { ChipListSkeleton } from '../templates/SkeletonPresets';
|
||||
|
||||
function fmtEur(val: number) {
|
||||
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(val);
|
||||
@@ -30,65 +32,30 @@ function BuchhaltungWidget() {
|
||||
|
||||
const isLoading = loadingJahre || (!!activeJahr && loadingStats);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>Buchhaltung</Typography>
|
||||
<Skeleton variant="rectangular" height={60} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!activeJahr) {
|
||||
return (
|
||||
<Card sx={{ cursor: 'pointer', '&:hover': { bgcolor: 'action.hover' } }} onClick={() => navigate('/buchhaltung')}>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
|
||||
<Typography variant="h6">Buchhaltung</Typography>
|
||||
<AccountBalance fontSize="small" color="action" />
|
||||
</Box>
|
||||
<Typography variant="body2" color="text.secondary">Kein aktives Haushaltsjahr</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !stats) {
|
||||
return (
|
||||
<Card sx={{ cursor: 'pointer', '&:hover': { bgcolor: 'action.hover' } }} onClick={() => navigate('/buchhaltung')}>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
|
||||
<Typography variant="h6">Buchhaltung</Typography>
|
||||
<AccountBalance fontSize="small" color="action" />
|
||||
</Box>
|
||||
<Typography variant="body2" color="text.secondary">Daten konnten nicht geladen werden</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const overBudgetCount = stats.konten_budget.filter(k => k.auslastung_prozent >= 80).length;
|
||||
const overBudgetCount = stats ? stats.konten_budget.filter(k => k.auslastung_prozent >= 80).length : 0;
|
||||
|
||||
return (
|
||||
<Card sx={{ cursor: 'pointer', '&:hover': { bgcolor: 'action.hover' } }} onClick={() => navigate('/buchhaltung')}>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1.5 }}>
|
||||
<Typography variant="h6">Buchhaltung</Typography>
|
||||
<AccountBalance fontSize="small" color="action" />
|
||||
</Box>
|
||||
<WidgetCard
|
||||
title="Buchhaltung"
|
||||
icon={<AccountBalance fontSize="small" color="action" />}
|
||||
isLoading={isLoading}
|
||||
skeleton={<ChipListSkeleton />}
|
||||
isError={isError || (!isLoading && !activeJahr)}
|
||||
errorMessage={!activeJahr ? 'Kein aktives Haushaltsjahr' : 'Daten konnten nicht geladen werden'}
|
||||
isEmpty={!isLoading && !isError && !!activeJahr && !stats}
|
||||
onClick={() => navigate('/buchhaltung')}
|
||||
>
|
||||
<Box sx={{ mb: 0.5 }}>
|
||||
<Typography variant="body2" color="text.secondary" component="span">Einnahmen: </Typography>
|
||||
<Typography variant="body2" color="success.main" component="span" fontWeight={600}>{fmtEur(stats.total_einnahmen)}</Typography>
|
||||
<Typography variant="body2" color="success.main" component="span" fontWeight={600}>{fmtEur(stats?.total_einnahmen ?? 0)}</Typography>
|
||||
</Box>
|
||||
<Box sx={{ mb: 0.5 }}>
|
||||
<Typography variant="body2" color="text.secondary" component="span">Ausgaben: </Typography>
|
||||
<Typography variant="body2" color="error.main" component="span" fontWeight={600}>{fmtEur(stats.total_ausgaben)}</Typography>
|
||||
<Typography variant="body2" color="error.main" component="span" fontWeight={600}>{fmtEur(stats?.total_ausgaben ?? 0)}</Typography>
|
||||
</Box>
|
||||
<Box sx={{ mb: overBudgetCount > 0 ? 1 : 0 }}>
|
||||
<Typography variant="body2" color="text.secondary" component="span">Saldo: </Typography>
|
||||
<Typography variant="body2" color={stats.saldo >= 0 ? 'success.main' : 'error.main'} component="span" fontWeight={600}>{fmtEur(stats.saldo)}</Typography>
|
||||
<Typography variant="body2" color={(stats?.saldo ?? 0) >= 0 ? 'success.main' : 'error.main'} component="span" fontWeight={600}>{fmtEur(stats?.saldo ?? 0)}</Typography>
|
||||
</Box>
|
||||
{overBudgetCount > 0 && (
|
||||
<Chip
|
||||
@@ -98,8 +65,7 @@ function BuchhaltungWidget() {
|
||||
variant="outlined"
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</WidgetCard>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Card, CardContent, Typography, Box, Chip, Skeleton } from '@mui/material';
|
||||
import { Typography, Box, Chip } from '@mui/material';
|
||||
import { AssignmentTurnedIn, Warning } from '@mui/icons-material';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { checklistenApi } from '../../services/checklisten';
|
||||
import { WidgetCard } from '../templates/WidgetCard';
|
||||
import { ChipListSkeleton } from '../templates/SkeletonPresets';
|
||||
|
||||
function ChecklistWidget() {
|
||||
const navigate = useNavigate();
|
||||
@@ -16,50 +18,23 @@ function ChecklistWidget() {
|
||||
|
||||
const overdueItems = overdue ?? [];
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>Checklisten</Typography>
|
||||
<Skeleton variant="rectangular" height={40} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>Checklisten</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Checklisten konnten nicht geladen werden.
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
const titleAction = !isLoading && !isError && overdueItems.length > 0 ? (
|
||||
<Chip icon={<Warning />} label={overdueItems.length} color="error" size="small" />
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<Card sx={{ cursor: 'pointer', '&:hover': { bgcolor: 'action.hover' } }} onClick={() => navigate('/checklisten')}>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="h6">Checklisten</Typography>
|
||||
{overdueItems.length > 0 && (
|
||||
<Chip
|
||||
icon={<Warning />}
|
||||
label={overdueItems.length}
|
||||
color="error"
|
||||
size="small"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<AssignmentTurnedIn fontSize="small" color="action" />
|
||||
</Box>
|
||||
{overdueItems.length === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary">Alle Checklisten aktuell</Typography>
|
||||
) : (
|
||||
<WidgetCard
|
||||
title="Checklisten"
|
||||
icon={<AssignmentTurnedIn fontSize="small" color="action" />}
|
||||
action={titleAction}
|
||||
isLoading={isLoading}
|
||||
skeleton={<ChipListSkeleton />}
|
||||
isError={isError}
|
||||
errorMessage="Checklisten konnten nicht geladen werden."
|
||||
isEmpty={!isLoading && !isError && overdueItems.length === 0}
|
||||
emptyMessage="Alle Checklisten aktuell"
|
||||
onClick={() => navigate('/checklisten')}
|
||||
>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
{overdueItems.slice(0, 5).map((item) => {
|
||||
const days = Math.ceil((Date.now() - new Date(item.naechste_faellig_am).getTime()) / 86400000);
|
||||
@@ -84,9 +59,7 @@ function ChecklistWidget() {
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</WidgetCard>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Box,
|
||||
TextField,
|
||||
Button,
|
||||
Switch,
|
||||
FormControlLabel,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { CalendarMonth } from '@mui/icons-material';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
@@ -15,6 +11,7 @@ import { eventsApi } from '../../services/events';
|
||||
import type { CreateVeranstaltungInput } from '../../types/events.types';
|
||||
import { useNotification } from '../../contexts/NotificationContext';
|
||||
import { usePermissionContext } from '../../contexts/PermissionContext';
|
||||
import { FormCard } from '../templates/FormCard';
|
||||
|
||||
function toDatetimeLocal(date: Date): string {
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
@@ -112,20 +109,13 @@ const EventQuickAddWidget: React.FC = () => {
|
||||
const datumBisValue = ganztaegig ? datumBis.slice(0, 10) : datumBis;
|
||||
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
height: '100%',
|
||||
transition: 'all 0.3s ease',
|
||||
'&:hover': { boxShadow: 3 },
|
||||
}}
|
||||
<FormCard
|
||||
title="Veranstaltung"
|
||||
icon={<CalendarMonth color="primary" />}
|
||||
isSubmitting={mutation.isPending}
|
||||
onSubmit={handleSubmit}
|
||||
submitLabel="Erstellen"
|
||||
>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<CalendarMonth color="primary" />
|
||||
<Typography variant="h6">Veranstaltung</Typography>
|
||||
</Box>
|
||||
|
||||
<Box component="form" onSubmit={handleSubmit} sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
@@ -186,19 +176,7 @@ const EventQuickAddWidget: React.FC = () => {
|
||||
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>
|
||||
</FormCard>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Card, CardContent, Typography, Box, Chip, Skeleton } from '@mui/material';
|
||||
import { Box, Chip } from '@mui/material';
|
||||
import { BugReport } from '@mui/icons-material';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { issuesApi } from '../../services/issues';
|
||||
import { WidgetCard } from '../templates/WidgetCard';
|
||||
import { ChipListSkeleton } from '../templates/SkeletonPresets';
|
||||
|
||||
function IssueOverviewWidget() {
|
||||
const navigate = useNavigate();
|
||||
@@ -14,53 +16,20 @@ function IssueOverviewWidget() {
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>Issues</Typography>
|
||||
<Skeleton variant="rectangular" height={40} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>Issues</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Issues konnten nicht geladen werden.
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const visibleCounts = data.filter((s) => s.count > 0);
|
||||
|
||||
if (visibleCounts.length === 0) {
|
||||
return (
|
||||
<Card sx={{ cursor: 'pointer' }} onClick={() => navigate('/issues')}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>Issues</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: 'text.secondary' }}>
|
||||
<BugReport fontSize="small" />
|
||||
<Typography variant="body2">Keine offenen Issues</Typography>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card sx={{ cursor: 'pointer' }} onClick={() => navigate('/issues')}>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1.5 }}>
|
||||
<Typography variant="h6">Issues</Typography>
|
||||
<BugReport fontSize="small" color="action" />
|
||||
</Box>
|
||||
<WidgetCard
|
||||
title="Issues"
|
||||
icon={<BugReport fontSize="small" color="action" />}
|
||||
isLoading={isLoading}
|
||||
skeleton={<ChipListSkeleton />}
|
||||
isError={isError}
|
||||
errorMessage="Issues konnten nicht geladen werden."
|
||||
isEmpty={!isLoading && !isError && visibleCounts.length === 0}
|
||||
emptyMessage="Keine offenen Issues"
|
||||
onClick={() => navigate('/issues')}
|
||||
>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{visibleCounts.map((s) => (
|
||||
<Chip
|
||||
@@ -71,8 +40,7 @@ function IssueOverviewWidget() {
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</WidgetCard>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,22 +1,19 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Box,
|
||||
TextField,
|
||||
Button,
|
||||
MenuItem,
|
||||
Select,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Skeleton,
|
||||
SelectChangeEvent,
|
||||
} from '@mui/material';
|
||||
import { BugReport } from '@mui/icons-material';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { issuesApi } from '../../services/issues';
|
||||
import { useNotification } from '../../contexts/NotificationContext';
|
||||
import { FormCard } from '../templates/FormCard';
|
||||
import { WidgetCard } from '../templates/WidgetCard';
|
||||
import { FormSkeleton } from '../templates/SkeletonPresets';
|
||||
|
||||
const PRIO_OPTIONS = [
|
||||
{ value: 'niedrig', label: 'Niedrig' },
|
||||
@@ -65,28 +62,25 @@ const IssueQuickAddWidget: React.FC = () => {
|
||||
mutation.mutate();
|
||||
};
|
||||
|
||||
if (typesLoading) {
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
height: '100%',
|
||||
transition: 'all 0.3s ease',
|
||||
'&:hover': { boxShadow: 3 },
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<BugReport color="primary" />
|
||||
<Typography variant="h6">Issue melden</Typography>
|
||||
</Box>
|
||||
<WidgetCard
|
||||
title="Issue melden"
|
||||
icon={<BugReport color="primary" />}
|
||||
isLoading
|
||||
skeleton={<FormSkeleton />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
{typesLoading ? (
|
||||
<Box>
|
||||
<Skeleton variant="rectangular" height={40} sx={{ mb: 1.5, borderRadius: 1 }} />
|
||||
<Skeleton variant="rectangular" height={40} sx={{ mb: 1.5, borderRadius: 1 }} />
|
||||
<Skeleton variant="rectangular" height={40} sx={{ borderRadius: 1 }} />
|
||||
</Box>
|
||||
) : (
|
||||
<Box component="form" onSubmit={handleSubmit} sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
return (
|
||||
<FormCard
|
||||
title="Issue melden"
|
||||
icon={<BugReport color="primary" />}
|
||||
isSubmitting={mutation.isPending}
|
||||
onSubmit={handleSubmit}
|
||||
submitLabel="Melden"
|
||||
>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
@@ -128,20 +122,7 @@ const IssueQuickAddWidget: React.FC = () => {
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
size="small"
|
||||
disabled={!titel.trim() || mutation.isPending}
|
||||
fullWidth
|
||||
>
|
||||
{mutation.isPending ? 'Wird erstellt…' : 'Melden'}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</FormCard>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Link,
|
||||
Stack,
|
||||
Box,
|
||||
Divider,
|
||||
} from '@mui/material';
|
||||
import { Link as LinkIcon, OpenInNew } from '@mui/icons-material';
|
||||
import type { LinkCollection } from '../../types/config.types';
|
||||
import { ListCard } from '../templates/ListCard';
|
||||
|
||||
interface LinksWidgetProps {
|
||||
collection: LinkCollection;
|
||||
@@ -16,17 +11,12 @@ interface LinksWidgetProps {
|
||||
|
||||
function LinksWidget({ collection }: LinksWidgetProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
|
||||
<LinkIcon color="primary" sx={{ mr: 1 }} />
|
||||
<Typography variant="h6">{collection.name}</Typography>
|
||||
</Box>
|
||||
<Divider sx={{ mb: 1.5 }} />
|
||||
<Stack spacing={0.5}>
|
||||
{collection.links.map((link, i) => (
|
||||
<ListCard
|
||||
title={collection.name}
|
||||
icon={<LinkIcon color="primary" />}
|
||||
items={collection.links}
|
||||
renderItem={(link) => (
|
||||
<Link
|
||||
key={i}
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
@@ -35,16 +25,13 @@ function LinksWidget({ collection }: LinksWidgetProps) {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
py: 0.5,
|
||||
}}
|
||||
>
|
||||
{link.name}
|
||||
<OpenInNew sx={{ fontSize: 14, opacity: 0.6 }} />
|
||||
</Link>
|
||||
))}
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,6 @@ import {
|
||||
Alert,
|
||||
AlertTitle,
|
||||
Box,
|
||||
CircularProgress,
|
||||
Divider,
|
||||
Link,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
@@ -14,6 +12,7 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { atemschutzApi } from '../../services/atemschutz';
|
||||
import type { User } from '../../types/auth.types';
|
||||
import type { AtemschutzUebersicht } from '../../types/atemschutz.types';
|
||||
import { WidgetCard } from '../templates/WidgetCard';
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -84,12 +83,11 @@ const PersonalWarningsBanner: React.FC<PersonalWarningsBannerProps> = ({ user: _
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 1 }}>
|
||||
<CircularProgress size={16} />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Persönliche Fristen werden geprüft…
|
||||
</Typography>
|
||||
</Box>
|
||||
<WidgetCard
|
||||
title="Persönliche Warnungen"
|
||||
icon={<NotificationsActiveIcon fontSize="small" />}
|
||||
isLoading
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -115,44 +113,20 @@ const PersonalWarningsBanner: React.FC<PersonalWarningsBannerProps> = ({ user: _
|
||||
const upcoming = warnings.filter((w) => w.tageRest >= 0);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
border: '1px solid',
|
||||
borderColor: overdue.length > 0 ? 'error.main' : 'warning.main',
|
||||
borderRadius: 1,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Banner header */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
px: 2,
|
||||
py: 1,
|
||||
bgcolor: overdue.length > 0 ? 'error.light' : 'warning.light',
|
||||
}}
|
||||
>
|
||||
<WidgetCard
|
||||
title={`Persönliche Warnungen (${warnings.length})`}
|
||||
icon={
|
||||
<NotificationsActiveIcon
|
||||
fontSize="small"
|
||||
sx={{ color: overdue.length > 0 ? 'error.dark' : 'warning.dark' }}
|
||||
/>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
}
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
color: overdue.length > 0 ? 'error.dark' : 'warning.dark',
|
||||
border: '1px solid',
|
||||
borderColor: overdue.length > 0 ? 'error.main' : 'warning.main',
|
||||
}}
|
||||
>
|
||||
Persönliche Warnungen ({warnings.length})
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Warning alerts */}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5, p: 1.5 }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
{overdue.length > 0 && (
|
||||
<Alert severity="error" variant="outlined">
|
||||
<AlertTitle sx={{ fontWeight: 600 }}>Überfällig — Handlungsbedarf</AlertTitle>
|
||||
@@ -209,7 +183,7 @@ const PersonalWarningsBanner: React.FC<PersonalWarningsBannerProps> = ({ user: _
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</WidgetCard>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardActionArea,
|
||||
CardContent,
|
||||
Typography,
|
||||
Box,
|
||||
Chip,
|
||||
} from '@mui/material';
|
||||
import { SvgIconComponent } from '@mui/icons-material';
|
||||
import { WidgetCard } from '../templates/WidgetCard';
|
||||
|
||||
interface ServiceCardProps {
|
||||
title: string;
|
||||
@@ -27,52 +25,27 @@ const ServiceCard: React.FC<ServiceCardProps> = ({
|
||||
const isConnected = status === 'connected';
|
||||
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
height: '100%',
|
||||
transition: 'all 0.3s ease',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-4px)',
|
||||
boxShadow: 4,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CardActionArea
|
||||
onClick={onClick}
|
||||
sx={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'stretch',
|
||||
justifyContent: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ flexGrow: 1, width: '100%' }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<WidgetCard
|
||||
title={title}
|
||||
icon={
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
bgcolor: isConnected ? 'success.light' : 'grey.300',
|
||||
borderRadius: '50%',
|
||||
p: 1.5,
|
||||
p: 1,
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
sx={{
|
||||
fontSize: 32,
|
||||
fontSize: 24,
|
||||
color: isConnected ? 'success.dark' : 'grey.600',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
}
|
||||
action={
|
||||
<Box
|
||||
sx={{
|
||||
width: 12,
|
||||
@@ -81,12 +54,9 @@ const ServiceCard: React.FC<ServiceCardProps> = ({
|
||||
bgcolor: isConnected ? 'success.main' : 'grey.400',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Typography variant="h6" component="div" gutterBottom>
|
||||
{title}
|
||||
</Typography>
|
||||
|
||||
}
|
||||
onClick={onClick}
|
||||
>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
{description}
|
||||
</Typography>
|
||||
@@ -97,9 +67,7 @@ const ServiceCard: React.FC<ServiceCardProps> = ({
|
||||
color={isConnected ? 'success' : 'default'}
|
||||
variant={isConnected ? 'filled' : 'outlined'}
|
||||
/>
|
||||
</CardContent>
|
||||
</CardActionArea>
|
||||
</Card>
|
||||
</WidgetCard>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,74 +1,3 @@
|
||||
import React from 'react';
|
||||
import { Card, CardContent, Typography, Box } from '@mui/material';
|
||||
import { SvgIconComponent } from '@mui/icons-material';
|
||||
|
||||
interface StatsCardProps {
|
||||
title: string;
|
||||
value: string | number;
|
||||
icon: SvgIconComponent;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
const StatsCard: React.FC<StatsCardProps> = ({
|
||||
title,
|
||||
value,
|
||||
icon: Icon,
|
||||
color = 'primary.main',
|
||||
}) => {
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
height: '100%',
|
||||
transition: 'all 0.3s ease',
|
||||
'&:hover': {
|
||||
boxShadow: 3,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
gutterBottom
|
||||
sx={{ textTransform: 'uppercase', fontSize: '0.75rem' }}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
<Typography variant="h4" component="div" sx={{ fontWeight: 'bold' }}>
|
||||
{value}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bgcolor: `${color}15`,
|
||||
borderRadius: '50%',
|
||||
width: 56,
|
||||
height: 56,
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
sx={{
|
||||
fontSize: 32,
|
||||
color: color,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatsCard;
|
||||
// Re-export StatCard from templates for backward compatibility
|
||||
export { StatCard as default } from '../templates/StatCard';
|
||||
export type { StatCardProps } from '../templates/StatCard';
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
CircularProgress,
|
||||
Chip,
|
||||
Divider,
|
||||
Link,
|
||||
List,
|
||||
ListItem,
|
||||
Skeleton,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { CalendarMonth as CalendarMonthIcon } from '@mui/icons-material';
|
||||
@@ -18,6 +13,8 @@ import { trainingApi } from '../../services/training';
|
||||
import { eventsApi } from '../../services/events';
|
||||
import type { UebungListItem, UebungTyp } from '../../types/training.types';
|
||||
import type { VeranstaltungListItem } from '../../types/events.types';
|
||||
import { ListCard } from '../templates/ListCard';
|
||||
import { WidgetCard } from '../templates/WidgetCard';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Color map — matches TYP_DOT_COLOR in Kalender.tsx
|
||||
@@ -136,72 +133,26 @@ const UpcomingEventsWidget: React.FC = () => {
|
||||
.slice(0, DISPLAY_LIMIT);
|
||||
}, [trainingItems, eventItems, loading]);
|
||||
|
||||
// ── Loading state ─────────────────────────────────────────────────────────
|
||||
if (loading) {
|
||||
return (
|
||||
<Card sx={{ height: '100%' }}>
|
||||
<CardContent sx={{ display: 'flex', alignItems: 'center', gap: 1.5, py: 2 }}>
|
||||
<CircularProgress size={18} />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Termine werden geladen...
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Error state ───────────────────────────────────────────────────────────
|
||||
// ── Render ────────────────────────────────────────────────────────────────
|
||||
if (error) {
|
||||
return (
|
||||
<Card sx={{ height: '100%' }}>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
|
||||
<CalendarMonthIcon fontSize="small" color="action" />
|
||||
<Typography variant="h6">Nächste Termine</Typography>
|
||||
</Box>
|
||||
<Typography variant="body2" color="error">
|
||||
Termine konnten nicht geladen werden.
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<WidgetCard
|
||||
title="Nächste Termine"
|
||||
icon={<CalendarMonthIcon fontSize="small" sx={{ color: 'primary.main' }} />}
|
||||
isError
|
||||
errorMessage="Termine konnten nicht geladen werden."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main render ───────────────────────────────────────────────────────────
|
||||
return (
|
||||
<Card
|
||||
<ListCard
|
||||
title="Nächste Termine"
|
||||
icon={<CalendarMonthIcon fontSize="small" sx={{ color: 'primary.main' }} />}
|
||||
items={entries}
|
||||
renderItem={(entry) => (
|
||||
<Box
|
||||
sx={{
|
||||
height: '100%',
|
||||
transition: 'box-shadow 0.3s ease',
|
||||
'&:hover': { boxShadow: 3 },
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ pb: '8px !important' }}>
|
||||
{/* Header */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||
<CalendarMonthIcon fontSize="small" sx={{ color: 'primary.main' }} />
|
||||
<Typography variant="h6" component="div">
|
||||
Nächste Termine
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ mb: 1 }} />
|
||||
|
||||
{/* Empty state */}
|
||||
{entries.length === 0 ? (
|
||||
<Box sx={{ textAlign: 'center', py: 3 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Keine bevorstehenden Termine
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<List disablePadding>
|
||||
{entries.map((entry, index) => (
|
||||
<React.Fragment key={entry.id}>
|
||||
<ListItem
|
||||
disableGutters
|
||||
sx={{
|
||||
py: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 1.5,
|
||||
@@ -271,18 +222,21 @@ const UpcomingEventsWidget: React.FC = () => {
|
||||
{entry.title}
|
||||
</Typography>
|
||||
</Box>
|
||||
</ListItem>
|
||||
|
||||
{index < entries.length - 1 && (
|
||||
<Divider component="li" sx={{ listStyle: 'none' }} />
|
||||
</Box>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
|
||||
{/* Footer link */}
|
||||
<Divider sx={{ mt: 1, mb: 1 }} />
|
||||
isLoading={loading}
|
||||
skeletonCount={5}
|
||||
skeletonItem={
|
||||
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'flex-start' }}>
|
||||
<Skeleton variant="circular" width={10} height={10} sx={{ mt: '4px' }} animation="wave" />
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Skeleton variant="text" width="70%" animation="wave" />
|
||||
<Skeleton variant="text" width="40%" animation="wave" />
|
||||
</Box>
|
||||
</Box>
|
||||
}
|
||||
emptyMessage="Keine bevorstehenden Termine"
|
||||
footer={
|
||||
<Box sx={{ textAlign: 'right' }}>
|
||||
<Link
|
||||
component={RouterLink}
|
||||
@@ -294,8 +248,8 @@ const UpcomingEventsWidget: React.FC = () => {
|
||||
Alle Termine
|
||||
</Link>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
CircularProgress,
|
||||
Chip,
|
||||
Divider,
|
||||
Link,
|
||||
List,
|
||||
ListItem,
|
||||
Skeleton,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { DirectionsCar as DirectionsCarIcon } from '@mui/icons-material';
|
||||
@@ -17,6 +12,8 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { bookingApi } from '../../services/bookings';
|
||||
import type { FahrzeugBuchungListItem } from '../../types/booking.types';
|
||||
import { BUCHUNGS_ART_COLORS, BUCHUNGS_ART_LABELS } from '../../types/booking.types';
|
||||
import { ListCard } from '../templates/ListCard';
|
||||
import { WidgetCard } from '../templates/WidgetCard';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
@@ -55,75 +52,28 @@ const VehicleBookingListWidget: React.FC = () => {
|
||||
[rawItems],
|
||||
);
|
||||
|
||||
// ── Loading state ─────────────────────────────────────────────────────────
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card sx={{ height: '100%' }}>
|
||||
<CardContent sx={{ display: 'flex', alignItems: 'center', gap: 1.5, py: 2 }}>
|
||||
<CircularProgress size={18} />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Buchungen werden geladen...
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Error state ───────────────────────────────────────────────────────────
|
||||
if (isError) {
|
||||
return (
|
||||
<Card sx={{ height: '100%' }}>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
|
||||
<DirectionsCarIcon fontSize="small" color="action" />
|
||||
<Typography variant="h6">Nächste Fahrzeugbuchungen</Typography>
|
||||
</Box>
|
||||
<Typography variant="body2" color="error">
|
||||
Buchungen konnten nicht geladen werden.
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<WidgetCard
|
||||
title="Nächste Fahrzeugbuchungen"
|
||||
icon={<DirectionsCarIcon fontSize="small" sx={{ color: 'primary.main' }} />}
|
||||
isError
|
||||
errorMessage="Buchungen konnten nicht geladen werden."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main render ───────────────────────────────────────────────────────────
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
height: '100%',
|
||||
transition: 'box-shadow 0.3s ease',
|
||||
'&:hover': { boxShadow: 3 },
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ pb: '8px !important' }}>
|
||||
{/* Header */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||
<DirectionsCarIcon fontSize="small" sx={{ color: 'primary.main' }} />
|
||||
<Typography variant="h6" component="div">
|
||||
Nächste Fahrzeugbuchungen
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ mb: 1 }} />
|
||||
|
||||
{/* Empty state */}
|
||||
{items.length === 0 ? (
|
||||
<Box sx={{ textAlign: 'center', py: 3 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Keine bevorstehenden Buchungen
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<List disablePadding>
|
||||
{items.map((booking, index) => {
|
||||
<ListCard
|
||||
title="Nächste Fahrzeugbuchungen"
|
||||
icon={<DirectionsCarIcon fontSize="small" sx={{ color: 'primary.main' }} />}
|
||||
items={items}
|
||||
renderItem={(booking) => {
|
||||
const color = BUCHUNGS_ART_COLORS[booking.buchungs_art] ?? '#9e9e9e';
|
||||
const label = BUCHUNGS_ART_LABELS[booking.buchungs_art] ?? booking.buchungs_art;
|
||||
return (
|
||||
<React.Fragment key={booking.id}>
|
||||
<ListItem
|
||||
disableGutters
|
||||
<Box
|
||||
sx={{
|
||||
py: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 1.5,
|
||||
@@ -205,19 +155,22 @@ const VehicleBookingListWidget: React.FC = () => {
|
||||
{booking.fahrzeug_name}
|
||||
</Typography>
|
||||
</Box>
|
||||
</ListItem>
|
||||
|
||||
{index < items.length - 1 && (
|
||||
<Divider component="li" sx={{ listStyle: 'none' }} />
|
||||
)}
|
||||
</React.Fragment>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
)}
|
||||
|
||||
{/* Footer link */}
|
||||
<Divider sx={{ mt: 1, mb: 1 }} />
|
||||
}}
|
||||
isLoading={isLoading}
|
||||
skeletonCount={5}
|
||||
skeletonItem={
|
||||
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'flex-start' }}>
|
||||
<Skeleton variant="circular" width={10} height={10} sx={{ mt: '4px' }} animation="wave" />
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Skeleton variant="text" width="70%" animation="wave" />
|
||||
<Skeleton variant="text" width="40%" animation="wave" />
|
||||
</Box>
|
||||
</Box>
|
||||
}
|
||||
emptyMessage="Keine bevorstehenden Buchungen"
|
||||
footer={
|
||||
<Box sx={{ textAlign: 'right' }}>
|
||||
<Link
|
||||
component={RouterLink}
|
||||
@@ -229,8 +182,8 @@ const VehicleBookingListWidget: React.FC = () => {
|
||||
Alle Buchungen
|
||||
</Link>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Box,
|
||||
TextField,
|
||||
Button,
|
||||
MenuItem,
|
||||
Select,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Skeleton,
|
||||
SelectChangeEvent,
|
||||
} from '@mui/material';
|
||||
import { DirectionsCar } from '@mui/icons-material';
|
||||
@@ -19,6 +13,9 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { bookingApi, fetchVehicles } from '../../services/bookings';
|
||||
import type { CreateBuchungInput } from '../../types/booking.types';
|
||||
import { useNotification } from '../../contexts/NotificationContext';
|
||||
import { FormCard } from '../templates/FormCard';
|
||||
import { WidgetCard } from '../templates/WidgetCard';
|
||||
import { FormSkeleton } from '../templates/SkeletonPresets';
|
||||
|
||||
function toDatetimeLocal(date: Date): string {
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
@@ -94,29 +91,25 @@ const VehicleBookingQuickAddWidget: React.FC = () => {
|
||||
mutation.mutate();
|
||||
};
|
||||
|
||||
if (vehiclesLoading) {
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
height: '100%',
|
||||
transition: 'all 0.3s ease',
|
||||
'&:hover': { boxShadow: 3 },
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<DirectionsCar color="primary" />
|
||||
<Typography variant="h6">Fahrzeugbuchung</Typography>
|
||||
</Box>
|
||||
<WidgetCard
|
||||
title="Fahrzeugbuchung"
|
||||
icon={<DirectionsCar color="primary" />}
|
||||
isLoading
|
||||
skeleton={<FormSkeleton />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
{vehiclesLoading ? (
|
||||
<Box>
|
||||
<Skeleton variant="rectangular" height={40} sx={{ mb: 1.5, borderRadius: 1 }} />
|
||||
<Skeleton variant="rectangular" height={40} sx={{ mb: 1.5, borderRadius: 1 }} />
|
||||
<Skeleton variant="rectangular" height={40} sx={{ mb: 1.5, borderRadius: 1 }} />
|
||||
<Skeleton variant="rectangular" height={40} sx={{ borderRadius: 1 }} />
|
||||
</Box>
|
||||
) : (
|
||||
<Box component="form" onSubmit={handleSubmit} sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
return (
|
||||
<FormCard
|
||||
title="Fahrzeugbuchung"
|
||||
icon={<DirectionsCar color="primary" />}
|
||||
isSubmitting={mutation.isPending}
|
||||
onSubmit={handleSubmit}
|
||||
submitLabel="Erstellen"
|
||||
>
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel>Fahrzeug</InputLabel>
|
||||
<Select
|
||||
@@ -172,20 +165,7 @@ const VehicleBookingQuickAddWidget: React.FC = () => {
|
||||
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>
|
||||
</FormCard>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Box,
|
||||
Divider,
|
||||
Skeleton,
|
||||
Chip,
|
||||
} from '@mui/material';
|
||||
@@ -16,6 +13,8 @@ import { vikunjaApi } from '../../services/vikunja';
|
||||
import type { VikunjaTask } from '../../types/vikunja.types';
|
||||
import { safeOpenUrl } from '../../utils/safeOpenUrl';
|
||||
import { useCountUp } from '../../hooks/useCountUp';
|
||||
import { ListCard } from '../templates/ListCard';
|
||||
import { WidgetCard } from '../templates/WidgetCard';
|
||||
|
||||
const PRIORITY_LABELS: Record<number, { label: string; color: 'default' | 'warning' | 'error' }> = {
|
||||
0: { label: 'Keine', color: 'default' },
|
||||
@@ -26,9 +25,8 @@ const PRIORITY_LABELS: Record<number, { label: string; color: 'default' | 'warni
|
||||
5: { label: 'Dringend', color: 'error' },
|
||||
};
|
||||
|
||||
const TaskRow: React.FC<{ task: VikunjaTask; showDivider: boolean; vikunjaUrl: string }> = ({
|
||||
const TaskRow: React.FC<{ task: VikunjaTask; vikunjaUrl: string }> = ({
|
||||
task,
|
||||
showDivider,
|
||||
vikunjaUrl,
|
||||
}) => {
|
||||
const handleClick = () => {
|
||||
@@ -42,14 +40,12 @@ const TaskRow: React.FC<{ task: VikunjaTask; showDivider: boolean; vikunjaUrl: s
|
||||
const priority = PRIORITY_LABELS[task.priority] ?? PRIORITY_LABELS[0];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
onClick={handleClick}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'space-between',
|
||||
py: 1.5,
|
||||
px: 1,
|
||||
cursor: 'pointer',
|
||||
borderRadius: 1,
|
||||
@@ -81,8 +77,6 @@ const TaskRow: React.FC<{ task: VikunjaTask; showDivider: boolean; vikunjaUrl: s
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
{showDivider && <Divider />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -100,78 +94,49 @@ const VikunjaMyTasksWidget: React.FC = () => {
|
||||
|
||||
if (!configured) {
|
||||
return (
|
||||
<Card sx={{ height: '100%' }}>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<AssignmentInd color="disabled" />
|
||||
<Typography variant="h6" color="text.secondary">
|
||||
Meine Aufgaben
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ py: 2, textAlign: 'center' }}>
|
||||
Vikunja nicht eingerichtet
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<WidgetCard
|
||||
title="Meine Aufgaben"
|
||||
icon={<AssignmentInd color="disabled" />}
|
||||
isEmpty
|
||||
emptyMessage="Vikunja nicht eingerichtet"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
height: '100%',
|
||||
transition: 'all 0.3s ease',
|
||||
'&:hover': { boxShadow: 3 },
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<AssignmentInd color="primary" />
|
||||
<Typography variant="h6" sx={{ flexGrow: 1 }}>
|
||||
Meine Aufgaben
|
||||
</Typography>
|
||||
{!isLoading && !isError && tasks.length > 0 && (
|
||||
<Chip label={animatedTaskCount} size="small" color="primary" />
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{isLoading && (
|
||||
<Box>
|
||||
{[1, 2, 3].map((n) => (
|
||||
<Box key={n} sx={{ mb: 1.5 }}>
|
||||
<Skeleton variant="text" width="70%" height={22} />
|
||||
<Skeleton variant="text" width="40%" height={18} />
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{isError && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ py: 2, textAlign: 'center' }}>
|
||||
Vikunja nicht erreichbar
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{!isLoading && !isError && tasks.length === 0 && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ py: 2, textAlign: 'center' }}>
|
||||
Keine offenen Aufgaben
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{!isLoading && !isError && tasks.length > 0 && (
|
||||
<Box>
|
||||
{tasks.map((task, index) => (
|
||||
<TaskRow
|
||||
key={task.id}
|
||||
task={task}
|
||||
showDivider={index < tasks.length - 1}
|
||||
vikunjaUrl={data?.vikunjaUrl ?? ''}
|
||||
<WidgetCard
|
||||
title="Meine Aufgaben"
|
||||
icon={<AssignmentInd color="primary" />}
|
||||
isError
|
||||
errorMessage="Vikunja nicht erreichbar"
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const titleAction = !isLoading && !isError && tasks.length > 0 ? (
|
||||
<Chip label={animatedTaskCount} size="small" color="primary" />
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<ListCard
|
||||
title="Meine Aufgaben"
|
||||
icon={<AssignmentInd color="primary" />}
|
||||
action={titleAction}
|
||||
items={tasks}
|
||||
renderItem={(task) => (
|
||||
<TaskRow key={task.id} task={task} vikunjaUrl={data?.vikunjaUrl ?? ''} />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
isLoading={isLoading}
|
||||
skeletonCount={3}
|
||||
skeletonItem={
|
||||
<Box sx={{ mb: 0.5 }}>
|
||||
<Skeleton variant="text" width="70%" height={22} animation="wave" />
|
||||
<Skeleton variant="text" width="40%" height={18} animation="wave" />
|
||||
</Box>
|
||||
}
|
||||
emptyMessage="Keine offenen Aufgaben"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Box,
|
||||
TextField,
|
||||
Button,
|
||||
MenuItem,
|
||||
Select,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Skeleton,
|
||||
SelectChangeEvent,
|
||||
} from '@mui/material';
|
||||
import { AddTask } from '@mui/icons-material';
|
||||
@@ -18,6 +12,9 @@ import GermanDateField from '../shared/GermanDateField';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { vikunjaApi } from '../../services/vikunja';
|
||||
import { useNotification } from '../../contexts/NotificationContext';
|
||||
import { FormCard } from '../templates/FormCard';
|
||||
import { WidgetCard } from '../templates/WidgetCard';
|
||||
import { FormSkeleton } from '../templates/SkeletonPresets';
|
||||
|
||||
const VikunjaQuickAddWidget: React.FC = () => {
|
||||
const [title, setTitle] = useState('');
|
||||
@@ -63,44 +60,34 @@ const VikunjaQuickAddWidget: React.FC = () => {
|
||||
|
||||
if (!configured) {
|
||||
return (
|
||||
<Card sx={{ height: '100%' }}>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<AddTask color="disabled" />
|
||||
<Typography variant="h6" color="text.secondary">
|
||||
Aufgabe erstellen
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ py: 2, textAlign: 'center' }}>
|
||||
Vikunja nicht eingerichtet
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<WidgetCard
|
||||
title="Aufgabe erstellen"
|
||||
icon={<AddTask color="disabled" />}
|
||||
isEmpty
|
||||
emptyMessage="Vikunja nicht eingerichtet"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (projectsLoading) {
|
||||
return (
|
||||
<WidgetCard
|
||||
title="Aufgabe erstellen"
|
||||
icon={<AddTask color="primary" />}
|
||||
isLoading
|
||||
skeleton={<FormSkeleton />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
height: '100%',
|
||||
transition: 'all 0.3s ease',
|
||||
'&:hover': { boxShadow: 3 },
|
||||
}}
|
||||
<FormCard
|
||||
title="Aufgabe erstellen"
|
||||
icon={<AddTask color="primary" />}
|
||||
isSubmitting={mutation.isPending}
|
||||
onSubmit={handleSubmit}
|
||||
submitLabel="Erstellen"
|
||||
>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<AddTask color="primary" />
|
||||
<Typography variant="h6">Aufgabe erstellen</Typography>
|
||||
</Box>
|
||||
|
||||
{projectsLoading ? (
|
||||
<Box>
|
||||
<Skeleton variant="rectangular" height={40} sx={{ mb: 1.5, borderRadius: 1 }} />
|
||||
<Skeleton variant="rectangular" height={40} sx={{ mb: 1.5, borderRadius: 1 }} />
|
||||
<Skeleton variant="rectangular" height={40} sx={{ borderRadius: 1 }} />
|
||||
</Box>
|
||||
) : (
|
||||
<Box component="form" onSubmit={handleSubmit} sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel>Projekt</InputLabel>
|
||||
<Select
|
||||
@@ -135,20 +122,7 @@ const VikunjaQuickAddWidget: React.FC = () => {
|
||||
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>
|
||||
</FormCard>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
|
||||
59
frontend/src/components/templates/ConfirmDialog.tsx
Normal file
59
frontend/src/components/templates/ConfirmDialog.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
238
frontend/src/components/templates/DataTable.tsx
Normal file
238
frontend/src/components/templates/DataTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
74
frontend/src/components/templates/DetailLayout.tsx
Normal file
74
frontend/src/components/templates/DetailLayout.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
90
frontend/src/components/templates/FilterBar.tsx
Normal file
90
frontend/src/components/templates/FilterBar.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
44
frontend/src/components/templates/FormCard.tsx
Normal file
44
frontend/src/components/templates/FormCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
52
frontend/src/components/templates/FormDialog.tsx
Normal file
52
frontend/src/components/templates/FormDialog.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
39
frontend/src/components/templates/FormLayout.tsx
Normal file
39
frontend/src/components/templates/FormLayout.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
59
frontend/src/components/templates/InfoGrid.tsx
Normal file
59
frontend/src/components/templates/InfoGrid.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
77
frontend/src/components/templates/ListCard.tsx
Normal file
77
frontend/src/components/templates/ListCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
frontend/src/components/templates/PageContainer.tsx
Normal file
19
frontend/src/components/templates/PageContainer.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
79
frontend/src/components/templates/PageHeader.tsx
Normal file
79
frontend/src/components/templates/PageHeader.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
106
frontend/src/components/templates/SkeletonPresets.tsx
Normal file
106
frontend/src/components/templates/SkeletonPresets.tsx
Normal 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>
|
||||
);
|
||||
81
frontend/src/components/templates/StatCard.tsx
Normal file
81
frontend/src/components/templates/StatCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
33
frontend/src/components/templates/StatusChip.tsx
Normal file
33
frontend/src/components/templates/StatusChip.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
50
frontend/src/components/templates/SummaryCards.tsx
Normal file
50
frontend/src/components/templates/SummaryCards.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
14
frontend/src/components/templates/TabPanel.tsx
Normal file
14
frontend/src/components/templates/TabPanel.tsx
Normal 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>;
|
||||
};
|
||||
144
frontend/src/components/templates/WidgetCard.tsx
Normal file
144
frontend/src/components/templates/WidgetCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
33
frontend/src/components/templates/index.ts
Normal file
33
frontend/src/components/templates/index.ts
Normal 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';
|
||||
@@ -195,14 +195,6 @@ function AdminSettings() {
|
||||
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
|
||||
const appLogoMutation = useMutation({
|
||||
mutationFn: (value: string) => settingsApi.update('app_logo', value),
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogTitle,
|
||||
FormControl,
|
||||
FormControlLabel,
|
||||
@@ -21,12 +20,6 @@ import {
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Select,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TextField,
|
||||
Tooltip,
|
||||
Typography,
|
||||
@@ -41,6 +34,8 @@ import {
|
||||
} from '@mui/icons-material';
|
||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
import ChatAwareFab from '../components/shared/ChatAwareFab';
|
||||
import { DataTable } from '../components/templates';
|
||||
import type { Column } from '../components/templates';
|
||||
import { atemschutzApi } from '../services/atemschutz';
|
||||
import { membersService } from '../services/members';
|
||||
import { useNotification } from '../contexts/NotificationContext';
|
||||
@@ -54,6 +49,7 @@ import type {
|
||||
UntersuchungErgebnis,
|
||||
} from '../types/atemschutz.types';
|
||||
import { UntersuchungErgebnisLabel } from '../types/atemschutz.types';
|
||||
import { ConfirmDialog } from '../components/templates';
|
||||
import type { MemberListItem } from '../types/member.types';
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
@@ -480,116 +476,86 @@ function Atemschutz() {
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
{!loading && !error && filtered.length > 0 && (
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Name</TableCell>
|
||||
<TableCell align="center">Lehrgang</TableCell>
|
||||
<TableCell>Untersuchung gültig bis</TableCell>
|
||||
<TableCell>Leistungstest gültig bis</TableCell>
|
||||
<TableCell align="center">Status</TableCell>
|
||||
{canWrite && <TableCell align="right">Aktionen</TableCell>}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{filtered.map((item) => {
|
||||
const untersuchungColor = getValidityColor(
|
||||
item.untersuchung_gueltig_bis,
|
||||
item.untersuchung_tage_rest,
|
||||
90
|
||||
);
|
||||
const leistungstestColor = getValidityColor(
|
||||
item.leistungstest_gueltig_bis,
|
||||
item.leistungstest_tage_rest,
|
||||
30
|
||||
);
|
||||
|
||||
return (
|
||||
<TableRow key={item.id} hover>
|
||||
<TableCell>
|
||||
<Typography variant="body2" fontWeight={500}>
|
||||
{getDisplayName(item)}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
{item.atemschutz_lehrgang ? (
|
||||
{!loading && !error && filtered.length > 0 && (() => {
|
||||
const columns: Column<AtemschutzUebersicht>[] = [
|
||||
{ key: 'user_name', label: 'Name', render: (item) => (
|
||||
<Typography variant="body2" fontWeight={500}>{getDisplayName(item)}</Typography>
|
||||
)},
|
||||
{ key: 'atemschutz_lehrgang', label: 'Lehrgang', align: 'center', render: (item) => (
|
||||
item.atemschutz_lehrgang ? (
|
||||
<Tooltip title={item.lehrgang_datum ? `Lehrgang am ${formatDate(item.lehrgang_datum)}` : 'Lehrgang absolviert'}>
|
||||
<Check color="success" fontSize="small" />
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Close color="disabled" fontSize="small" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Tooltip
|
||||
title={
|
||||
)
|
||||
)},
|
||||
{ key: 'untersuchung_gueltig_bis', label: 'Untersuchung gültig bis', render: (item) => (
|
||||
<Tooltip title={
|
||||
item.untersuchung_tage_rest !== null
|
||||
? item.untersuchung_tage_rest < 0
|
||||
? `Seit ${Math.abs(item.untersuchung_tage_rest)} Tagen abgelaufen`
|
||||
: `Noch ${item.untersuchung_tage_rest} Tage gültig`
|
||||
: 'Keine Untersuchung eingetragen'
|
||||
}
|
||||
>
|
||||
<Typography variant="body2" color={untersuchungColor} fontWeight={500}>
|
||||
}>
|
||||
<Typography variant="body2" color={getValidityColor(item.untersuchung_gueltig_bis, item.untersuchung_tage_rest, 90)} fontWeight={500}>
|
||||
{formatDate(item.untersuchung_gueltig_bis)}
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Tooltip
|
||||
title={
|
||||
)},
|
||||
{ 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={leistungstestColor} fontWeight={500}>
|
||||
}>
|
||||
<Typography variant="body2" color={getValidityColor(item.leistungstest_gueltig_bis, item.leistungstest_tage_rest, 30)} fontWeight={500}>
|
||||
{formatDate(item.leistungstest_gueltig_bis)}
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
)},
|
||||
{ key: 'einsatzbereit', label: 'Status', align: 'center', render: (item) => (
|
||||
<Chip
|
||||
label={item.einsatzbereit ? 'Einsatzbereit' : 'Nicht einsatzbereit'}
|
||||
color={item.einsatzbereit ? 'success' : 'error'}
|
||||
size="small"
|
||||
variant="filled"
|
||||
/>
|
||||
</TableCell>
|
||||
{canWrite && (
|
||||
<TableCell align="right">
|
||||
)},
|
||||
];
|
||||
|
||||
if (canWrite) {
|
||||
columns.push({
|
||||
key: 'actions', label: 'Aktionen', align: 'right', sortable: false, searchable: false, render: (item) => (
|
||||
<>
|
||||
<Tooltip title="Bearbeiten">
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => handleOpenEdit(item)}
|
||||
sx={{ minWidth: 'auto', mr: 0.5 }}
|
||||
>
|
||||
<Button size="small" onClick={(e) => { e.stopPropagation(); 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' }}
|
||||
>
|
||||
<Button size="small" color="error" onClick={(e) => { e.stopPropagation(); setDeleteId(item.id); }} sx={{ minWidth: 'auto' }}>
|
||||
<Delete fontSize="small" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
</>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={filtered}
|
||||
rowKey={(item) => item.id}
|
||||
emptyMessage={traeger.length === 0 ? 'Keine Atemschutzträger vorhanden' : 'Keine Ergebnisse gefunden'}
|
||||
searchEnabled={false}
|
||||
paginationEnabled={false}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
})()}
|
||||
|
||||
{/* FAB to create */}
|
||||
{canWrite && (
|
||||
@@ -808,29 +774,16 @@ function Atemschutz() {
|
||||
</Dialog>
|
||||
|
||||
{/* ── Delete Confirmation Dialog ──────────────────────────────────── */}
|
||||
<Dialog open={deleteId !== null} onClose={() => setDeleteId(null)}>
|
||||
<DialogTitle>Atemschutzträger löschen</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
Soll dieser Atemschutzträger-Eintrag wirklich gelöscht werden? Diese Aktion kann
|
||||
nicht rückgängig gemacht werden.
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<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>
|
||||
<ConfirmDialog
|
||||
open={deleteId !== null}
|
||||
onClose={() => setDeleteId(null)}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
title="Atemschutzträger löschen"
|
||||
message="Soll dieser Atemschutzträger-Eintrag wirklich gelöscht werden? Diese Aktion kann nicht rückgängig gemacht werden."
|
||||
confirmLabel="Löschen"
|
||||
confirmColor="error"
|
||||
isLoading={deleteLoading}
|
||||
/>
|
||||
</Container>
|
||||
</DashboardLayout>
|
||||
);
|
||||
|
||||
@@ -9,11 +9,6 @@ import {
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Container,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogTitle,
|
||||
FormControl,
|
||||
FormControlLabel,
|
||||
Grid,
|
||||
@@ -41,14 +36,12 @@ import {
|
||||
Add as AddIcon,
|
||||
Build,
|
||||
CheckCircle,
|
||||
Close,
|
||||
Delete,
|
||||
Edit,
|
||||
Error as ErrorIcon,
|
||||
LinkRounded,
|
||||
PauseCircle,
|
||||
RemoveCircle,
|
||||
Save,
|
||||
Search,
|
||||
Star,
|
||||
Warning,
|
||||
@@ -68,6 +61,7 @@ import {
|
||||
import { usePermissions } from '../hooks/usePermissions';
|
||||
import { useNotification } from '../contexts/NotificationContext';
|
||||
import ChatAwareFab from '../components/shared/ChatAwareFab';
|
||||
import { ConfirmDialog, FormDialog } from '../components/templates';
|
||||
|
||||
// ── Status chip config ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -416,18 +410,18 @@ function AusruestungTypenSettings() {
|
||||
)}
|
||||
|
||||
{/* Add/Edit dialog */}
|
||||
<Dialog open={dialogOpen} onClose={closeDialog} maxWidth="sm" fullWidth>
|
||||
<DialogTitle sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
{editingTyp ? 'Typ bearbeiten' : 'Neuen Typ erstellen'}
|
||||
<IconButton onClick={closeDialog} size="small"><Close /></IconButton>
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<FormDialog
|
||||
open={dialogOpen}
|
||||
onClose={closeDialog}
|
||||
onSubmit={handleSave}
|
||||
title={editingTyp ? 'Typ bearbeiten' : 'Neuen Typ erstellen'}
|
||||
isSubmitting={isSaving}
|
||||
>
|
||||
<TextField
|
||||
label="Name *"
|
||||
fullWidth
|
||||
value={formName}
|
||||
onChange={(e) => setFormName(e.target.value)}
|
||||
sx={{ mt: 1, mb: 2 }}
|
||||
inputProps={{ maxLength: 100 }}
|
||||
/>
|
||||
<TextField
|
||||
@@ -437,7 +431,6 @@ function AusruestungTypenSettings() {
|
||||
rows={2}
|
||||
value={formBeschreibung}
|
||||
onChange={(e) => setFormBeschreibung(e.target.value)}
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
<TextField
|
||||
label="Icon (MUI Icon-Name)"
|
||||
@@ -446,44 +439,19 @@ function AusruestungTypenSettings() {
|
||||
onChange={(e) => setFormIcon(e.target.value)}
|
||||
placeholder="z.B. Build, LocalFireDepartment"
|
||||
/>
|
||||
</DialogContent>
|
||||
<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>
|
||||
</FormDialog>
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
<Dialog open={deleteDialogOpen} onClose={() => !deleteMutation.isPending && setDeleteDialogOpen(false)}>
|
||||
<DialogTitle>Typ löschen</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
Möchten Sie den Typ "{deletingTyp?.name}" wirklich löschen?
|
||||
Geräte, denen dieser Typ zugeordnet ist, verlieren die Zuordnung.
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<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>
|
||||
<ConfirmDialog
|
||||
open={deleteDialogOpen}
|
||||
onClose={() => setDeleteDialogOpen(false)}
|
||||
onConfirm={() => deletingTyp && deleteMutation.mutate(deletingTyp.id)}
|
||||
title="Typ löschen"
|
||||
message={<>Möchten Sie den Typ "{deletingTyp?.name}" wirklich löschen? Geräte, denen dieser Typ zugeordnet ist, verlieren die Zuordnung.</>}
|
||||
confirmLabel="Löschen"
|
||||
confirmColor="error"
|
||||
isLoading={deleteMutation.isPending}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogTitle,
|
||||
Divider,
|
||||
FormControl,
|
||||
@@ -20,14 +19,12 @@ import {
|
||||
Paper,
|
||||
Select,
|
||||
Stack,
|
||||
Tab,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Tabs,
|
||||
TextField,
|
||||
Tooltip,
|
||||
Typography,
|
||||
@@ -44,14 +41,13 @@ import {
|
||||
MoreHoriz,
|
||||
PauseCircle,
|
||||
RemoveCircle,
|
||||
Save,
|
||||
Star,
|
||||
Verified,
|
||||
Warning,
|
||||
} from '@mui/icons-material';
|
||||
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 { DetailLayout, ConfirmDialog } from '../components/templates';
|
||||
import ChatAwareFab from '../components/shared/ChatAwareFab';
|
||||
import { equipmentApi } from '../services/equipment';
|
||||
import { fromGermanDate } from '../utils/dateInput';
|
||||
@@ -68,20 +64,6 @@ import {
|
||||
import { usePermissions } from '../hooks/usePermissions';
|
||||
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 ------------------------------------------------------------
|
||||
|
||||
const STATUS_ICONS: Record<AusruestungStatus, React.ReactElement> = {
|
||||
@@ -202,7 +184,7 @@ interface UebersichtTabProps {
|
||||
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 [newStatus, setNewStatus] = useState<AusruestungStatus>(equipment.status);
|
||||
const [bemerkung, setBemerkung] = useState(equipment.status_bemerkung ?? '');
|
||||
@@ -690,7 +672,6 @@ function AusruestungDetailPage() {
|
||||
const [equipment, setEquipment] = useState<AusruestungDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||
|
||||
@@ -761,36 +742,46 @@ function AusruestungDetailPage() {
|
||||
};
|
||||
const canWrite = canManageCategory(equipmentKategorie);
|
||||
|
||||
const subtitle = [
|
||||
equipment.kategorie_name,
|
||||
equipment.seriennummer ? `SN: ${equipment.seriennummer}` : null,
|
||||
].filter(Boolean).join(' · ');
|
||||
const tabs = [
|
||||
{
|
||||
label: 'Übersicht',
|
||||
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 (
|
||||
<DashboardLayout>
|
||||
<Container maxWidth="lg">
|
||||
<Button
|
||||
startIcon={<ArrowBack />}
|
||||
onClick={() => navigate('/ausruestung')}
|
||||
sx={{ mb: 2 }}
|
||||
size="small"
|
||||
>
|
||||
Ausrüstungsübersicht
|
||||
</Button>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 1 }}>
|
||||
<Build sx={{ fontSize: 36, color: 'text.secondary' }} />
|
||||
<Box>
|
||||
<Typography variant="h4" component="h1">
|
||||
{equipment.bezeichnung}
|
||||
</Typography>
|
||||
{subtitle && (
|
||||
<Typography variant="subtitle1" color="text.secondary">
|
||||
{subtitle}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Box sx={{ ml: 'auto', display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<DetailLayout
|
||||
title={equipment.bezeichnung}
|
||||
backTo="/ausruestung"
|
||||
tabs={tabs}
|
||||
actions={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Chip
|
||||
icon={STATUS_ICONS[equipment.status]}
|
||||
label={AusruestungStatusLabel[equipment.status]}
|
||||
@@ -820,74 +811,20 @@ function AusruestungDetailPage() {
|
||||
</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 */}
|
||||
<Dialog open={deleteDialogOpen} onClose={() => !deleteLoading && setDeleteDialogOpen(false)}>
|
||||
<DialogTitle>Gerät löschen</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
Möchten Sie '{equipment.bezeichnung}' wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
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>
|
||||
<ConfirmDialog
|
||||
open={deleteDialogOpen}
|
||||
onClose={() => !deleteLoading && setDeleteDialogOpen(false)}
|
||||
onConfirm={handleDelete}
|
||||
title="Gerät löschen"
|
||||
message={`Möchten Sie '${equipment.bezeichnung}' wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.`}
|
||||
confirmLabel="Löschen"
|
||||
confirmColor="error"
|
||||
isLoading={deleteLoading}
|
||||
/>
|
||||
</Container>
|
||||
</DashboardLayout>
|
||||
);
|
||||
|
||||
@@ -16,9 +16,10 @@ import {
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { ArrowBack, Save } from '@mui/icons-material';
|
||||
import { Save } from '@mui/icons-material';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
import { PageHeader } from '../components/templates';
|
||||
import { toGermanDate, fromGermanDate, isValidGermanDate } from '../utils/dateInput';
|
||||
import { equipmentApi } from '../services/equipment';
|
||||
import { vehiclesApi } from '../services/vehicles';
|
||||
@@ -288,7 +289,7 @@ function AusruestungForm() {
|
||||
<DashboardLayout>
|
||||
<Container maxWidth="md">
|
||||
<Alert severity="error">{error}</Alert>
|
||||
<Button startIcon={<ArrowBack />} onClick={() => navigate('/ausruestung')} sx={{ mt: 2 }}>
|
||||
<Button onClick={() => navigate('/ausruestung')} sx={{ mt: 2 }}>
|
||||
Zurück
|
||||
</Button>
|
||||
</Container>
|
||||
@@ -301,18 +302,15 @@ function AusruestungForm() {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<Container maxWidth="md">
|
||||
<Button
|
||||
startIcon={<ArrowBack />}
|
||||
onClick={() => (isEditMode && id ? navigate(`/ausruestung/${id}`) : navigate('/ausruestung'))}
|
||||
sx={{ mb: 2 }}
|
||||
size="small"
|
||||
>
|
||||
{isEditMode ? 'Zurück zur Detailansicht' : 'Ausrüstungsübersicht'}
|
||||
</Button>
|
||||
|
||||
<Typography variant="h4" gutterBottom>
|
||||
{isEditMode ? 'Gerät bearbeiten' : 'Neues Gerät anlegen'}
|
||||
</Typography>
|
||||
<PageHeader
|
||||
title={isEditMode ? 'Gerät bearbeiten' : 'Neues Gerät anlegen'}
|
||||
breadcrumbs={[
|
||||
{ label: 'Ausrüstung', href: '/ausruestung' },
|
||||
...(isEditMode && id ? [{ label: 'Detail', href: `/ausruestung/${id}` }] : []),
|
||||
{ label: isEditMode ? 'Bearbeiten' : 'Neu' },
|
||||
]}
|
||||
backTo={isEditMode && id ? `/ausruestung/${id}` : '/ausruestung'}
|
||||
/>
|
||||
|
||||
{saveError && <Alert severity="error" sx={{ mb: 2 }}>{saveError}</Alert>}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import {
|
||||
Box, Tab, Tabs, Typography, Grid, Button, Chip,
|
||||
Box, Tab, Tabs, Typography, Grid, Chip,
|
||||
Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper,
|
||||
TextField, IconButton, MenuItem,
|
||||
TextField, MenuItem,
|
||||
} from '@mui/material';
|
||||
import { Add as AddIcon } from '@mui/icons-material';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
@@ -335,7 +335,7 @@ export default function AusruestungsanfrageArtikelDetail() {
|
||||
const val = e.target.value ? Number(e.target.value) : '';
|
||||
setMainKat(val);
|
||||
if (val) {
|
||||
const subs = subKategorienOf(val as number);
|
||||
subKategorienOf(val as number);
|
||||
setForm(f => ({ ...f, kategorie_id: val as number }));
|
||||
} else {
|
||||
setForm(f => ({ ...f, kategorie_id: null }));
|
||||
|
||||
@@ -14,10 +14,6 @@ import {
|
||||
TableRow,
|
||||
TextField,
|
||||
IconButton,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Grid,
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -32,7 +28,6 @@ import {
|
||||
Tooltip,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ArrowBack,
|
||||
Add as AddIcon,
|
||||
Delete as DeleteIcon,
|
||||
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 type { BestellungStatus, BestellpositionFormData, ErinnerungFormData } from '../types/bestellung.types';
|
||||
import type { AusruestungArtikel, AusruestungEigenschaft } from '../types/ausruestungsanfrage.types';
|
||||
import { ConfirmDialog, StatusChip, PageHeader } from '../components/templates';
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
@@ -674,11 +670,11 @@ export default function BestellungDetail() {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
{/* ── Header ── */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
|
||||
<IconButton onClick={() => navigate('/bestellungen')}>
|
||||
<ArrowBack />
|
||||
</IconButton>
|
||||
<Typography variant="h4" sx={{ flexGrow: 1 }}>{bestellung.bezeichnung}</Typography>
|
||||
<PageHeader
|
||||
title={bestellung.bezeichnung}
|
||||
backTo="/bestellungen"
|
||||
actions={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{canExport && !editMode && (
|
||||
<Tooltip title={bestellung.status === 'entwurf' || bestellung.status === 'wartet_auf_genehmigung' ? 'Export erst nach Genehmigung verfügbar' : 'PDF Export'}>
|
||||
<span>
|
||||
@@ -703,11 +699,15 @@ export default function BestellungDetail() {
|
||||
<Button onClick={cancelEditMode} disabled={isSavingAll}>Abbrechen</Button>
|
||||
</>
|
||||
)}
|
||||
<Chip
|
||||
label={BESTELLUNG_STATUS_LABELS[bestellung.status]}
|
||||
color={BESTELLUNG_STATUS_COLORS[bestellung.status]}
|
||||
<StatusChip
|
||||
status={bestellung.status}
|
||||
labelMap={BESTELLUNG_STATUS_LABELS}
|
||||
colorMap={BESTELLUNG_STATUS_COLORS}
|
||||
size="medium"
|
||||
/>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* ── Info Cards ── */}
|
||||
{editMode ? (
|
||||
@@ -1344,9 +1344,13 @@ export default function BestellungDetail() {
|
||||
{/* ══════════════════════════════════════════════════════════════════════ */}
|
||||
|
||||
{/* Status Confirmation */}
|
||||
<Dialog open={statusConfirmTarget != null} onClose={() => { setStatusConfirmTarget(null); setStatusForce(false); }}>
|
||||
<DialogTitle>Status ändern{statusForce ? ' (manuell)' : ''}</DialogTitle>
|
||||
<DialogContent>
|
||||
<ConfirmDialog
|
||||
open={statusConfirmTarget != null}
|
||||
onClose={() => { setStatusConfirmTarget(null); setStatusForce(false); }}
|
||||
onConfirm={() => statusConfirmTarget && updateStatus.mutate({ status: statusConfirmTarget, force: statusForce || undefined })}
|
||||
title={`Status ändern${statusForce ? ' (manuell)' : ''}`}
|
||||
message={
|
||||
<>
|
||||
<Typography>
|
||||
Status von <strong>{BESTELLUNG_STATUS_LABELS[bestellung.status]}</strong> auf{' '}
|
||||
<strong>{statusConfirmTarget ? BESTELLUNG_STATUS_LABELS[statusConfirmTarget] : ''}</strong> ändern?
|
||||
@@ -1361,56 +1365,47 @@ export default function BestellungDetail() {
|
||||
Nicht alle Positionen haben einen Einzelpreis. Bitte prüfen Sie die Kosten, bevor Sie den Status ändern.
|
||||
</Alert>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => { setStatusConfirmTarget(null); setStatusForce(false); }}>Abbrechen</Button>
|
||||
<Button variant="contained" onClick={() => statusConfirmTarget && updateStatus.mutate({ status: statusConfirmTarget, force: statusForce || undefined })} disabled={updateStatus.isPending}>
|
||||
Bestätigen
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
}
|
||||
confirmLabel="Bestätigen"
|
||||
isLoading={updateStatus.isPending}
|
||||
/>
|
||||
|
||||
{/* Delete Item Confirmation */}
|
||||
<Dialog open={deleteItemTarget != null} onClose={() => setDeleteItemTarget(null)}>
|
||||
<DialogTitle>Position löschen</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography>Soll diese Position wirklich gelöscht werden?</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDeleteItemTarget(null)}>Abbrechen</Button>
|
||||
<Button color="error" variant="contained" onClick={() => deleteItemTarget != null && deleteItem.mutate(deleteItemTarget)} disabled={deleteItem.isPending}>
|
||||
Löschen
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
<ConfirmDialog
|
||||
open={deleteItemTarget != null}
|
||||
onClose={() => setDeleteItemTarget(null)}
|
||||
onConfirm={() => deleteItemTarget != null && deleteItem.mutate(deleteItemTarget)}
|
||||
title="Position löschen"
|
||||
message="Soll diese Position wirklich gelöscht werden?"
|
||||
confirmLabel="Löschen"
|
||||
confirmColor="error"
|
||||
isLoading={deleteItem.isPending}
|
||||
/>
|
||||
|
||||
{/* Delete File Confirmation */}
|
||||
<Dialog open={deleteFileTarget != null} onClose={() => setDeleteFileTarget(null)}>
|
||||
<DialogTitle>Datei löschen</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography>Soll diese Datei wirklich gelöscht werden?</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDeleteFileTarget(null)}>Abbrechen</Button>
|
||||
<Button color="error" variant="contained" onClick={() => deleteFileTarget != null && deleteFile.mutate(deleteFileTarget)} disabled={deleteFile.isPending}>
|
||||
Löschen
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
<ConfirmDialog
|
||||
open={deleteFileTarget != null}
|
||||
onClose={() => setDeleteFileTarget(null)}
|
||||
onConfirm={() => deleteFileTarget != null && deleteFile.mutate(deleteFileTarget)}
|
||||
title="Datei löschen"
|
||||
message="Soll diese Datei wirklich gelöscht werden?"
|
||||
confirmLabel="Löschen"
|
||||
confirmColor="error"
|
||||
isLoading={deleteFile.isPending}
|
||||
/>
|
||||
|
||||
{/* Delete Reminder Confirmation */}
|
||||
<Dialog open={deleteReminderTarget != null} onClose={() => setDeleteReminderTarget(null)}>
|
||||
<DialogTitle>Erinnerung löschen</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography>Soll diese Erinnerung wirklich gelöscht werden?</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDeleteReminderTarget(null)}>Abbrechen</Button>
|
||||
<Button color="error" variant="contained" onClick={() => deleteReminderTarget != null && deleteReminder.mutate(deleteReminderTarget)} disabled={deleteReminder.isPending}>
|
||||
Löschen
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
<ConfirmDialog
|
||||
open={deleteReminderTarget != null}
|
||||
onClose={() => setDeleteReminderTarget(null)}
|
||||
onConfirm={() => deleteReminderTarget != null && deleteReminder.mutate(deleteReminderTarget)}
|
||||
title="Erinnerung löschen"
|
||||
message="Soll diese Erinnerung wirklich gelöscht werden?"
|
||||
confirmLabel="Löschen"
|
||||
confirmColor="error"
|
||||
isLoading={deleteReminder.isPending}
|
||||
/>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,13 +10,13 @@ import {
|
||||
Tooltip,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ArrowBack,
|
||||
Add as AddIcon,
|
||||
RemoveCircleOutline as RemoveIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
import { PageHeader, FormLayout } from '../components/templates';
|
||||
import { useNotification } from '../contexts/NotificationContext';
|
||||
import { bestellungApi } from '../services/bestellung';
|
||||
import type { BestellungFormData, BestellpositionFormData, LieferantFormData, Lieferant } from '../types/bestellung.types';
|
||||
@@ -75,16 +75,27 @@ export default function BestellungNeu() {
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
{/* ── Header ── */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||
<IconButton onClick={() => navigate('/bestellungen')}>
|
||||
<ArrowBack />
|
||||
</IconButton>
|
||||
<Typography variant="h5" fontWeight={700}>Neue Bestellung</Typography>
|
||||
</Box>
|
||||
<PageHeader
|
||||
title="Neue Bestellung"
|
||||
breadcrumbs={[
|
||||
{ label: 'Bestellungen', href: '/bestellungen' },
|
||||
{ label: 'Neue Bestellung' },
|
||||
]}
|
||||
backTo="/bestellungen"
|
||||
/>
|
||||
|
||||
{/* ── Form ── */}
|
||||
<Paper sx={{ p: 3, display: 'flex', flexDirection: 'column', gap: 2.5 }}>
|
||||
<FormLayout
|
||||
actions={<>
|
||||
<Button onClick={() => navigate('/bestellungen')}>Abbrechen</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleSubmit}
|
||||
disabled={!orderForm.bezeichnung.trim() || createOrder.isPending}
|
||||
>
|
||||
Erstellen
|
||||
</Button>
|
||||
</>}
|
||||
>
|
||||
<TextField
|
||||
label="Bezeichnung"
|
||||
required
|
||||
@@ -207,17 +218,7 @@ export default function BestellungNeu() {
|
||||
</Button>
|
||||
|
||||
{/* ── Submit ── */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1, mt: 2 }}>
|
||||
<Button onClick={() => navigate('/bestellungen')}>Abbrechen</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleSubmit}
|
||||
disabled={!orderForm.bezeichnung.trim() || createOrder.isPending}
|
||||
>
|
||||
Erstellen
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
</FormLayout>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,13 +9,6 @@ import {
|
||||
Tabs,
|
||||
Tooltip,
|
||||
Typography,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Paper,
|
||||
Chip,
|
||||
Button,
|
||||
Checkbox,
|
||||
@@ -23,8 +16,6 @@ import {
|
||||
FormGroup,
|
||||
LinearProgress,
|
||||
Divider,
|
||||
TextField,
|
||||
MenuItem,
|
||||
} from '@mui/material';
|
||||
import { Add as AddIcon, ExpandMore as ExpandMoreIcon, FilterList as FilterListIcon, PictureAsPdf as PdfIcon } from '@mui/icons-material';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
@@ -38,6 +29,8 @@ import { configApi } from '../services/config';
|
||||
import { addPdfHeader, addPdfFooter } from '../utils/pdfExport';
|
||||
import { BESTELLUNG_STATUS_LABELS, BESTELLUNG_STATUS_COLORS } 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 ──
|
||||
|
||||
@@ -261,18 +254,16 @@ export default function Bestellungen() {
|
||||
{/* ── Tab 0: Orders ── */}
|
||||
<TabPanel value={tab} index={0}>
|
||||
{/* ── Summary Cards ── */}
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 2, mb: 3 }}>
|
||||
{[
|
||||
{ label: 'Wartet auf Genehmigung', count: orders.filter(o => o.status === 'wartet_auf_genehmigung').length, color: 'warning.main' },
|
||||
{ label: 'Bereit zur Bestellung', count: orders.filter(o => o.status === 'bereit_zur_bestellung').length, color: 'info.main' },
|
||||
{ label: 'Bestellt', count: orders.filter(o => o.status === 'bestellt').length, color: 'primary.main' },
|
||||
{ label: 'Lieferung prüfen', count: orders.filter(o => o.status === 'lieferung_pruefen').length, color: 'secondary.main' },
|
||||
].map(({ label, count, color }) => (
|
||||
<Paper variant="outlined" key={label} sx={{ p: 2, textAlign: 'center' }}>
|
||||
<Typography variant="h4" sx={{ color, fontWeight: 700 }}>{count}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">{label}</Typography>
|
||||
</Paper>
|
||||
))}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<SummaryCards
|
||||
stats={[
|
||||
{ label: 'Wartet auf Genehmigung', value: orders.filter(o => o.status === 'wartet_auf_genehmigung').length, color: 'warning.main' },
|
||||
{ label: 'Bereit zur Bestellung', value: orders.filter(o => o.status === 'bereit_zur_bestellung').length, color: 'info.main' },
|
||||
{ label: 'Bestellt', value: orders.filter(o => o.status === 'bestellt').length, color: 'primary.main' },
|
||||
{ label: 'Lieferung prüfen', value: orders.filter(o => o.status === 'lieferung_pruefen').length, color: 'secondary.main' },
|
||||
] as SummaryStat[]}
|
||||
isLoading={ordersLoading}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* ── Filter ── */}
|
||||
@@ -335,77 +326,39 @@ export default function Bestellungen() {
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Kennung</TableCell>
|
||||
<TableCell>Bezeichnung</TableCell>
|
||||
<TableCell>Lieferant</TableCell>
|
||||
<TableCell>Besteller</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell align="right">Positionen</TableCell>
|
||||
<TableCell align="right">Gesamtpreis (brutto)</TableCell>
|
||||
<TableCell>Lieferung</TableCell>
|
||||
<TableCell>Erstellt am</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{ordersLoading ? (
|
||||
<TableRow><TableCell colSpan={9} align="center">Laden...</TableCell></TableRow>
|
||||
) : filteredOrders.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={9} align="center">Keine Bestellungen vorhanden</TableCell></TableRow>
|
||||
) : (
|
||||
filteredOrders.map((o) => {
|
||||
const brutto = calcBrutto(o);
|
||||
<DataTable<Bestellung>
|
||||
columns={[
|
||||
{ key: 'laufende_nummer', label: 'Kennung', width: 90, render: (o) => (
|
||||
<Typography sx={{ whiteSpace: 'nowrap', fontFamily: 'monospace', fontSize: '0.85rem' }}>{formatKennung(o)}</Typography>
|
||||
)},
|
||||
{ key: 'bezeichnung', label: 'Bezeichnung' },
|
||||
{ key: 'lieferant_name', label: 'Lieferant', render: (o) => o.lieferant_name || '–' },
|
||||
{ key: 'besteller_name', label: 'Besteller', render: (o) => o.besteller_name || '–' },
|
||||
{ key: 'status', label: 'Status', render: (o) => (
|
||||
<StatusChip status={o.status} labelMap={BESTELLUNG_STATUS_LABELS} colorMap={BESTELLUNG_STATUS_COLORS} />
|
||||
)},
|
||||
{ key: 'items_count', label: 'Positionen', align: 'right', render: (o) => o.items_count ?? 0 },
|
||||
{ key: 'total_cost', label: 'Gesamtpreis (brutto)', align: 'right', render: (o) => formatCurrency(calcBrutto(o)) },
|
||||
{ key: 'total_received', label: 'Lieferung', render: (o) => {
|
||||
const totalOrdered = o.total_ordered ?? 0;
|
||||
const totalReceived = o.total_received ?? 0;
|
||||
const deliveryPct = totalOrdered > 0 ? (totalReceived / totalOrdered) * 100 : 0;
|
||||
return (
|
||||
<TableRow
|
||||
key={o.id}
|
||||
hover
|
||||
sx={{ cursor: 'pointer' }}
|
||||
onClick={() => navigate(`/bestellungen/${o.id}`)}
|
||||
>
|
||||
<TableCell sx={{ whiteSpace: 'nowrap', fontFamily: 'monospace', fontSize: '0.85rem' }}>
|
||||
{formatKennung(o)}
|
||||
</TableCell>
|
||||
<TableCell>{o.bezeichnung}</TableCell>
|
||||
<TableCell>{o.lieferant_name || '–'}</TableCell>
|
||||
<TableCell>{o.besteller_name || '–'}</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={BESTELLUNG_STATUS_LABELS[o.status]}
|
||||
color={BESTELLUNG_STATUS_COLORS[o.status]}
|
||||
size="small"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="right">{o.items_count ?? 0}</TableCell>
|
||||
<TableCell align="right">{formatCurrency(brutto)}</TableCell>
|
||||
<TableCell sx={{ minWidth: 100 }}>
|
||||
{totalOrdered > 0 ? (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={Math.min(deliveryPct, 100)}
|
||||
color={deliveryPct >= 100 ? 'success' : 'primary'}
|
||||
sx={{ flexGrow: 1, height: 6, borderRadius: 3 }}
|
||||
/>
|
||||
<Typography variant="caption" sx={{ whiteSpace: 'nowrap' }}>
|
||||
{totalReceived}/{totalOrdered}
|
||||
</Typography>
|
||||
return totalOrdered > 0 ? (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 100 }}>
|
||||
<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>
|
||||
) : '–';
|
||||
}},
|
||||
{ key: 'erstellt_am', label: 'Erstellt am', render: (o) => formatDate(o.erstellt_am) },
|
||||
]}
|
||||
data={filteredOrders}
|
||||
rowKey={(o) => o.id}
|
||||
onRowClick={(o) => navigate(`/bestellungen/${o.id}`)}
|
||||
isLoading={ordersLoading}
|
||||
emptyMessage="Keine Bestellungen vorhanden"
|
||||
searchEnabled={false}
|
||||
/>
|
||||
|
||||
{hasPermission('bestellungen:create') && (
|
||||
<ChatAwareFab onClick={() => navigate('/bestellungen/neu')} aria-label="Neue Bestellung">
|
||||
@@ -417,45 +370,22 @@ export default function Bestellungen() {
|
||||
{/* ── Tab 1: Vendors ── */}
|
||||
{canManageVendors && (
|
||||
<TabPanel value={tab} index={1}>
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Name</TableCell>
|
||||
<TableCell>Kontakt</TableCell>
|
||||
<TableCell>E-Mail</TableCell>
|
||||
<TableCell>Telefon</TableCell>
|
||||
<TableCell>Website</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{vendorsLoading ? (
|
||||
<TableRow><TableCell colSpan={5} align="center">Laden...</TableCell></TableRow>
|
||||
) : vendors.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={5} align="center">Keine Lieferanten vorhanden</TableCell></TableRow>
|
||||
) : (
|
||||
vendors.map((v) => (
|
||||
<TableRow
|
||||
key={v.id}
|
||||
hover
|
||||
sx={{ cursor: 'pointer' }}
|
||||
onClick={() => navigate(`/bestellungen/lieferanten/${v.id}`)}
|
||||
>
|
||||
<TableCell>{v.name}</TableCell>
|
||||
<TableCell>{v.kontakt_name || '–'}</TableCell>
|
||||
<TableCell>{v.email ? <a href={`mailto:${v.email}`} onClick={(e) => e.stopPropagation()}>{v.email}</a> : '–'}</TableCell>
|
||||
<TableCell>{v.telefon || '–'}</TableCell>
|
||||
<TableCell>
|
||||
{v.website ? (
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'name', label: 'Name' },
|
||||
{ key: 'kontakt_name', label: 'Kontakt', render: (v) => v.kontakt_name || '–' },
|
||||
{ key: 'email', label: 'E-Mail', render: (v) => v.email ? <a href={`mailto:${v.email}`} onClick={(e) => e.stopPropagation()}>{v.email}</a> : '–' },
|
||||
{ key: 'telefon', label: 'Telefon', render: (v) => v.telefon || '–' },
|
||||
{ key: 'website', label: 'Website', render: (v) => 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>
|
||||
) : '–' },
|
||||
]}
|
||||
data={vendors}
|
||||
rowKey={(v) => v.id}
|
||||
onRowClick={(v) => navigate(`/bestellungen/lieferanten/${v.id}`)}
|
||||
isLoading={vendorsLoading}
|
||||
emptyMessage="Keine Lieferanten vorhanden"
|
||||
/>
|
||||
|
||||
<ChatAwareFab onClick={() => navigate('/bestellungen/lieferanten/neu')} aria-label="Lieferant hinzufügen">
|
||||
<AddIcon />
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
CircularProgress,
|
||||
Alert,
|
||||
Switch,
|
||||
IconButton,
|
||||
Chip,
|
||||
Stack,
|
||||
Card,
|
||||
@@ -22,7 +21,6 @@ import {
|
||||
Grid,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ArrowBack,
|
||||
CheckCircle,
|
||||
Warning,
|
||||
Block,
|
||||
@@ -30,6 +28,7 @@ import {
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
import { PageHeader } from '../components/templates';
|
||||
import ServiceModePage from '../components/shared/ServiceModePage';
|
||||
import GermanDateField from '../components/shared/GermanDateField';
|
||||
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||
@@ -243,15 +242,14 @@ function BookingFormPage() {
|
||||
<ServiceModePage message="Fahrzeugbuchungen befinden sich aktuell im Wartungsmodus." />
|
||||
) : (
|
||||
<Container maxWidth="md" sx={{ py: 3 }}>
|
||||
{/* Page header */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 3 }}>
|
||||
<IconButton onClick={() => navigate('/fahrzeugbuchungen')}>
|
||||
<ArrowBack />
|
||||
</IconButton>
|
||||
<Typography variant="h5" sx={{ ml: 1 }}>
|
||||
{isEdit ? 'Buchung bearbeiten' : 'Neue Buchung'}
|
||||
</Typography>
|
||||
</Box>
|
||||
<PageHeader
|
||||
title={isEdit ? 'Buchung bearbeiten' : 'Neue Buchung'}
|
||||
breadcrumbs={[
|
||||
{ label: 'Fahrzeugbuchungen', href: '/fahrzeugbuchungen' },
|
||||
{ label: isEdit ? 'Bearbeiten' : 'Neue Buchung' },
|
||||
]}
|
||||
backTo="/fahrzeugbuchungen"
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
|
||||
@@ -90,6 +90,8 @@ import {
|
||||
KONTO_ART_LABELS,
|
||||
} from '../types/buchhaltung.types';
|
||||
|
||||
import { StatusChip } from '../components/templates';
|
||||
|
||||
// ─── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function fmtEur(val: number) {
|
||||
@@ -1392,7 +1394,7 @@ function TransaktionenTab({ haushaltsjahre, selectedJahrId, onJahrChange }: {
|
||||
{t.typ === 'ausgabe' ? '-' : t.typ === 'einnahme' ? '+' : ''}{fmtEur(t.betrag)}
|
||||
</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>
|
||||
<Stack direction="row" spacing={0.5}>
|
||||
|
||||
@@ -3,8 +3,7 @@ import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Box, Button, TextField, Typography, Paper, Stack, MenuItem, Select,
|
||||
FormControl, InputLabel, Alert, Dialog, DialogTitle,
|
||||
DialogContent, DialogActions, Skeleton, Divider, LinearProgress, Grid,
|
||||
FormControl, InputLabel, Alert, Skeleton, Divider, LinearProgress, Grid,
|
||||
ToggleButton, ToggleButtonGroup,
|
||||
} from '@mui/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 { useNotification } from '../contexts/NotificationContext';
|
||||
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);
|
||||
|
||||
@@ -290,26 +290,21 @@ export default function BuchhaltungKontoManage() {
|
||||
</Paper>
|
||||
</Stack>
|
||||
|
||||
<Dialog open={deleteOpen} onClose={() => setDeleteOpen(false)}>
|
||||
<DialogTitle>Konto löschen</DialogTitle>
|
||||
<DialogContent>
|
||||
<ConfirmDialog
|
||||
open={deleteOpen}
|
||||
onClose={() => setDeleteOpen(false)}
|
||||
onConfirm={() => { deleteMut.mutate(); setDeleteOpen(false); }}
|
||||
title="Konto löschen"
|
||||
message={
|
||||
<Typography>
|
||||
Möchten Sie das Konto <strong>{konto.kontonummer} — {konto.bezeichnung}</strong> wirklich löschen?
|
||||
Diese Aktion kann nicht rückgängig gemacht werden.
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDeleteOpen(false)}>Abbrechen</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="error"
|
||||
onClick={() => { deleteMut.mutate(); setDeleteOpen(false); }}
|
||||
disabled={deleteMut.isPending}
|
||||
>
|
||||
Löschen
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
}
|
||||
confirmLabel="Löschen"
|
||||
confirmColor="error"
|
||||
isLoading={deleteMut.isPending}
|
||||
/>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import { usePermissionContext } from '../contexts/PermissionContext';
|
||||
import { useNotification } from '../contexts/NotificationContext';
|
||||
import { checklistenApi } from '../services/checklisten';
|
||||
import { CHECKLIST_STATUS_LABELS, CHECKLIST_STATUS_COLORS } from '../types/checklist.types';
|
||||
import { StatusChip } from '../components/templates';
|
||||
import type { ChecklistAusfuehrungItem } from '../types/checklist.types';
|
||||
|
||||
// ── Helpers ──
|
||||
@@ -255,9 +256,11 @@ export default function ChecklistAusfuehrung() {
|
||||
{execution.fahrzeug_name ?? execution.ausruestung_name ?? '–'} · {formatDate(execution.ausgefuehrt_am ?? execution.created_at)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Chip
|
||||
label={CHECKLIST_STATUS_LABELS[execution.status]}
|
||||
color={CHECKLIST_STATUS_COLORS[execution.status]}
|
||||
<StatusChip
|
||||
status={execution.status}
|
||||
labelMap={CHECKLIST_STATUS_LABELS}
|
||||
colorMap={CHECKLIST_STATUS_COLORS}
|
||||
size="medium"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -52,6 +52,8 @@ import {
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
import { TabPanel, DataTable } from '../components/templates';
|
||||
import type { Column } from '../components/templates';
|
||||
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||
import { useNotification } from '../contexts/NotificationContext';
|
||||
import { checklistenApi } from '../services/checklisten';
|
||||
@@ -124,14 +126,6 @@ function getDueLabel(nextDue?: string | null, intervall?: string | null): string
|
||||
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
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
@@ -783,6 +777,17 @@ function HistorieTab({ executions, loading, navigate }: HistorieTabProps) {
|
||||
|
||||
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 (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', gap: 2, mb: 2 }}>
|
||||
@@ -804,38 +809,14 @@ function HistorieTab({ executions, loading, navigate }: HistorieTabProps) {
|
||||
</FormControl>
|
||||
</Box>
|
||||
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Fahrzeug / Ausrüstung</TableCell>
|
||||
<TableCell>Vorlage</TableCell>
|
||||
<TableCell>Datum</TableCell>
|
||||
<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>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={filtered}
|
||||
rowKey={(e) => e.id}
|
||||
onRowClick={(e) => navigate(`/checklisten/ausfuehrung/${e.id}`)}
|
||||
emptyMessage="Keine Einträge"
|
||||
searchEnabled={false}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,12 +7,6 @@ import {
|
||||
Button,
|
||||
Chip,
|
||||
Paper,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TablePagination,
|
||||
TextField,
|
||||
Grid,
|
||||
@@ -36,6 +30,7 @@ import {
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
import { DataTable } from '../components/templates';
|
||||
import { fromGermanDate } from '../utils/dateInput';
|
||||
import IncidentStatsChart from '../components/incidents/IncidentStatsChart';
|
||||
import {
|
||||
@@ -395,99 +390,48 @@ function Einsaetze() {
|
||||
)}
|
||||
|
||||
{/* Incident table */}
|
||||
<Paper>
|
||||
<TableContainer>
|
||||
<Table size="small" aria-label="Einsatzliste">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell sx={{ fontWeight: 700, whiteSpace: 'nowrap' }}>Datum / Uhrzeit</TableCell>
|
||||
<TableCell sx={{ fontWeight: 700 }}>Nr.</TableCell>
|
||||
<TableCell sx={{ fontWeight: 700 }}>Einsatzart</TableCell>
|
||||
<TableCell sx={{ fontWeight: 700 }}>Stichwort</TableCell>
|
||||
<TableCell sx={{ fontWeight: 700 }}>Ort</TableCell>
|
||||
<TableCell sx={{ fontWeight: 700, textAlign: 'right' }}>Hilfsfrist</TableCell>
|
||||
<TableCell sx={{ fontWeight: 700, textAlign: 'right' }}>Dauer</TableCell>
|
||||
<TableCell sx={{ fontWeight: 700 }}>Status</TableCell>
|
||||
<TableCell sx={{ fontWeight: 700 }}>Einsatzleiter</TableCell>
|
||||
<TableCell sx={{ fontWeight: 700, textAlign: 'right' }}>Kräfte</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{listLoading
|
||||
? Array.from({ length: rowsPerPage > 10 ? 10 : rowsPerPage }).map((_, i) => (
|
||||
<TableRow key={i}>
|
||||
{Array.from({ length: 10 }).map((__, j) => (
|
||||
<TableCell key={j}>
|
||||
<Skeleton variant="text" />
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
: items.length === 0
|
||||
? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={10} align="center" sx={{ py: 6 }}>
|
||||
<Box sx={{ color: 'text.disabled', fontSize: 48, mb: 1 }}>
|
||||
<LocalFireDepartment fontSize="inherit" />
|
||||
</Box>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
Keine Einsätze gefunden
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
: 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' }}
|
||||
<DataTable<EinsatzListItem>
|
||||
columns={[
|
||||
{ key: 'alarm_time', label: 'Datum / Uhrzeit', render: (row) => (
|
||||
<Typography sx={{ whiteSpace: 'nowrap', fontSize: '0.8125rem' }}>{formatDE(row.alarm_time)}</Typography>
|
||||
)},
|
||||
{ key: 'einsatz_nr', label: 'Nr.', render: (row) => (
|
||||
<Typography sx={{ fontFamily: 'monospace', fontSize: '0.8rem' }}>{row.einsatz_nr}</Typography>
|
||||
)},
|
||||
{ key: 'einsatz_art', label: 'Einsatzart', render: (row) => (
|
||||
<Chip label={row.einsatz_art} color={ART_CHIP_COLOR[row.einsatz_art]} size="small" sx={{ fontSize: '0.7rem' }} />
|
||||
)},
|
||||
{ key: 'einsatz_stichwort', label: 'Stichwort', render: (row) => (
|
||||
<Typography sx={{ fontSize: '0.8125rem' }}>{row.einsatz_stichwort ?? '—'}</Typography>
|
||||
)},
|
||||
{ key: 'strasse', label: 'Ort', render: (row) => (
|
||||
<Typography sx={{ fontSize: '0.8125rem' }}>{[row.strasse, row.ort].filter(Boolean).join(', ') || '—'}</Typography>
|
||||
)},
|
||||
{ key: 'hilfsfrist_min', label: 'Hilfsfrist', align: 'right', render: (row) => (
|
||||
<Typography sx={{ fontSize: '0.8125rem', whiteSpace: 'nowrap' }}>{durationLabel(row.hilfsfrist_min)}</Typography>
|
||||
)},
|
||||
{ key: 'dauer_min', label: 'Dauer', align: 'right', render: (row) => (
|
||||
<Typography sx={{ fontSize: '0.8125rem', whiteSpace: 'nowrap' }}>{durationLabel(row.dauer_min)}</Typography>
|
||||
)},
|
||||
{ key: 'status', label: 'Status', render: (row) => (
|
||||
<Chip label={EINSATZ_STATUS_LABELS[row.status]} color={STATUS_CHIP_COLOR[row.status]} size="small" sx={{ fontSize: '0.7rem' }} />
|
||||
)},
|
||||
{ key: 'einsatzleiter_name', label: 'Einsatzleiter', render: (row) => (
|
||||
<Typography sx={{ fontSize: '0.8125rem' }}>{row.einsatzleiter_name ?? '—'}</Typography>
|
||||
)},
|
||||
{ key: 'personal_count', label: 'Kräfte', align: 'right', render: (row) => (
|
||||
<Typography sx={{ fontSize: '0.8125rem' }}>{row.personal_count > 0 ? row.personal_count : '—'}</Typography>
|
||||
)},
|
||||
]}
|
||||
data={items}
|
||||
rowKey={(row) => row.id}
|
||||
onRowClick={(row) => handleRowClick(row.id)}
|
||||
isLoading={listLoading}
|
||||
emptyMessage="Keine Einsätze gefunden"
|
||||
emptyIcon={<LocalFireDepartment sx={{ fontSize: 40, mb: 1, opacity: 0.5 }} />}
|
||||
searchEnabled={false}
|
||||
paginationEnabled={false}
|
||||
/>
|
||||
</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
|
||||
component="div"
|
||||
@@ -502,7 +446,6 @@ function Einsaetze() {
|
||||
`${from}–${to} von ${count !== -1 ? count : `mehr als ${to}`}`
|
||||
}
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
{/* Create dialog */}
|
||||
<CreateEinsatzDialog
|
||||
|
||||
@@ -27,7 +27,6 @@ import {
|
||||
AccessTime,
|
||||
DirectionsCar,
|
||||
People,
|
||||
LocationOn,
|
||||
Description,
|
||||
PictureAsPdf,
|
||||
} from '@mui/icons-material';
|
||||
@@ -44,6 +43,7 @@ import {
|
||||
} from '../services/incidents';
|
||||
import { useNotification } from '../contexts/NotificationContext';
|
||||
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||
import { PageHeader } from '../components/templates';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// COLOUR MAPS
|
||||
@@ -280,16 +280,24 @@ function EinsatzDetail() {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<Container maxWidth="lg">
|
||||
{/* Back + Actions */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Button
|
||||
startIcon={<ArrowBack />}
|
||||
onClick={() => navigate('/einsaetze')}
|
||||
variant="text"
|
||||
>
|
||||
Zurück
|
||||
</Button>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<PageHeader
|
||||
title={`Einsatz ${einsatz.einsatz_nr}`}
|
||||
subtitle={address || undefined}
|
||||
backTo="/einsaetze"
|
||||
actions={
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<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"
|
||||
/>
|
||||
<Tooltip title="PDF exportieren (Vorschau)">
|
||||
<Button
|
||||
variant="outlined"
|
||||
@@ -333,41 +341,14 @@ function EinsatzDetail() {
|
||||
</>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* HEADER */}
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flexWrap: 'wrap', mb: 1 }}>
|
||||
<Chip
|
||||
icon={<LocalFireDepartment />}
|
||||
label={EINSATZ_ART_LABELS[einsatz.einsatz_art]}
|
||||
color={ART_CHIP_COLOR[einsatz.einsatz_art]}
|
||||
sx={{ fontWeight: 600 }}
|
||||
/>
|
||||
<Chip
|
||||
label={EINSATZ_STATUS_LABELS[einsatz.status]}
|
||||
color={STATUS_CHIP_COLOR[einsatz.status]}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
/>
|
||||
{einsatz.einsatz_stichwort && (
|
||||
<Typography variant="h6" color="text.secondary">
|
||||
<Typography variant="h6" color="text.secondary" sx={{ mb: 2, mt: -2 }}>
|
||||
{einsatz.einsatz_stichwort}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Typography variant="h4" fontWeight={700}>
|
||||
Einsatz {einsatz.einsatz_nr}
|
||||
</Typography>
|
||||
{address && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.5 }}>
|
||||
<LocationOn fontSize="small" color="action" />
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
{address}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
{/* LEFT COLUMN: Timeline + Vehicles */}
|
||||
|
||||
@@ -21,14 +21,12 @@ import {
|
||||
Paper,
|
||||
Select,
|
||||
Stack,
|
||||
Tab,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Tabs,
|
||||
TextField,
|
||||
Tooltip,
|
||||
Typography,
|
||||
@@ -40,7 +38,6 @@ import {
|
||||
Build,
|
||||
CheckCircle,
|
||||
DeleteOutline,
|
||||
DirectionsCar,
|
||||
Edit,
|
||||
Error as ErrorIcon,
|
||||
History,
|
||||
@@ -55,6 +52,7 @@ import {
|
||||
} from '@mui/icons-material';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
import { DetailLayout } from '../components/templates';
|
||||
import ChatAwareFab from '../components/shared/ChatAwareFab';
|
||||
import { vehiclesApi } from '../services/vehicles';
|
||||
import GermanDateField from '../components/shared/GermanDateField';
|
||||
@@ -81,20 +79,6 @@ import { usePermissionContext } from '../contexts/PermissionContext';
|
||||
import { useNotification } from '../contexts/NotificationContext';
|
||||
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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
const STATUS_ICONS: Record<FahrzeugStatus, React.ReactElement> = {
|
||||
@@ -190,7 +174,7 @@ interface UebersichtTabProps {
|
||||
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 [newStatus, setNewStatus] = useState<FahrzeugStatus>(vehicle.status);
|
||||
const [bemerkung, setBemerkung] = useState(vehicle.status_bemerkung ?? '');
|
||||
@@ -889,7 +873,6 @@ function FahrzeugDetail() {
|
||||
const [vehicle, setVehicle] = useState<FahrzeugDetailType | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||
const [vehicleEquipment, setVehicleEquipment] = useState<AusruestungListItem[]>([]);
|
||||
@@ -958,36 +941,74 @@ function FahrzeugDetail() {
|
||||
(vehicle.paragraph57a_tage_bis_faelligkeit !== null && vehicle.paragraph57a_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 (
|
||||
<DashboardLayout>
|
||||
<Container maxWidth="lg">
|
||||
<Button
|
||||
startIcon={<ArrowBack />}
|
||||
onClick={() => navigate('/fahrzeuge')}
|
||||
sx={{ mb: 2 }}
|
||||
size="small"
|
||||
>
|
||||
Fahrzeugübersicht
|
||||
</Button>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 1 }}>
|
||||
<DirectionsCar sx={{ fontSize: 36, color: 'text.secondary' }} />
|
||||
<Box>
|
||||
<Typography variant="h4" component="h1">
|
||||
{vehicle.bezeichnung}
|
||||
{vehicle.kurzname && (
|
||||
<Typography component="span" variant="h5" color="text.secondary" sx={{ ml: 1 }}>
|
||||
{vehicle.kurzname}
|
||||
</Typography>
|
||||
)}
|
||||
</Typography>
|
||||
{vehicle.amtliches_kennzeichen && (
|
||||
<Typography variant="subtitle1" color="text.secondary">
|
||||
{vehicle.amtliches_kennzeichen}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Box sx={{ ml: 'auto', display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<DetailLayout
|
||||
title={titleText}
|
||||
backTo="/fahrzeuge"
|
||||
tabs={tabs}
|
||||
actions={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Chip
|
||||
icon={STATUS_ICONS[vehicle.status]}
|
||||
label={FahrzeugStatusLabel[vehicle.status]}
|
||||
@@ -1017,71 +1038,8 @@ function FahrzeugDetail() {
|
||||
</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 */}
|
||||
<Dialog open={deleteDialogOpen} onClose={() => !deleteLoading && setDeleteDialogOpen(false)}>
|
||||
|
||||
@@ -12,9 +12,10 @@ import {
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { ArrowBack, Save } from '@mui/icons-material';
|
||||
import { Save } from '@mui/icons-material';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
import { PageHeader } from '../components/templates';
|
||||
import GermanDateField from '../components/shared/GermanDateField';
|
||||
import { vehiclesApi } from '../services/vehicles';
|
||||
import { fahrzeugTypenApi } from '../services/fahrzeugTypen';
|
||||
@@ -232,7 +233,7 @@ function FahrzeugForm() {
|
||||
<DashboardLayout>
|
||||
<Container maxWidth="md">
|
||||
<Alert severity="error">{error}</Alert>
|
||||
<Button startIcon={<ArrowBack />} onClick={() => navigate('/fahrzeuge')} sx={{ mt: 2 }}>
|
||||
<Button onClick={() => navigate('/fahrzeuge')} sx={{ mt: 2 }}>
|
||||
Zurück
|
||||
</Button>
|
||||
</Container>
|
||||
@@ -243,18 +244,15 @@ function FahrzeugForm() {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<Container maxWidth="md">
|
||||
<Button
|
||||
startIcon={<ArrowBack />}
|
||||
onClick={() => (isEditMode && id ? navigate(`/fahrzeuge/${id}`) : navigate('/fahrzeuge'))}
|
||||
sx={{ mb: 2 }}
|
||||
size="small"
|
||||
>
|
||||
{isEditMode ? 'Zurück zur Detailansicht' : 'Fahrzeugübersicht'}
|
||||
</Button>
|
||||
|
||||
<Typography variant="h4" gutterBottom>
|
||||
{isEditMode ? 'Fahrzeug bearbeiten' : 'Neues Fahrzeug erfassen'}
|
||||
</Typography>
|
||||
<PageHeader
|
||||
title={isEditMode ? 'Fahrzeug bearbeiten' : 'Neues Fahrzeug erfassen'}
|
||||
breadcrumbs={[
|
||||
{ label: 'Fahrzeuge', href: '/fahrzeuge' },
|
||||
...(isEditMode && id ? [{ label: 'Detail', href: `/fahrzeuge/${id}` }] : []),
|
||||
{ label: isEditMode ? 'Bearbeiten' : 'Neu' },
|
||||
]}
|
||||
backTo={isEditMode && id ? `/fahrzeuge/${id}` : '/fahrzeuge'}
|
||||
/>
|
||||
|
||||
{saveError && <Alert severity="error" sx={{ mb: 2 }}>{saveError}</Alert>}
|
||||
|
||||
|
||||
@@ -10,10 +10,6 @@ import {
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Container,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Grid,
|
||||
IconButton,
|
||||
InputAdornment,
|
||||
@@ -63,6 +59,7 @@ import type { FahrzeugTyp } from '../types/checklist.types';
|
||||
import { usePermissions } from '../hooks/usePermissions';
|
||||
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||
import { useNotification } from '../contexts/NotificationContext';
|
||||
import { FormDialog } from '../components/templates';
|
||||
|
||||
// ── Status chip config ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -447,11 +444,13 @@ function FahrzeugTypenSettings() {
|
||||
</>
|
||||
)}
|
||||
|
||||
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>
|
||||
{editing ? 'Fahrzeugtyp bearbeiten' : 'Neuer Fahrzeugtyp'}
|
||||
</DialogTitle>
|
||||
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
|
||||
<FormDialog
|
||||
open={dialogOpen}
|
||||
onClose={() => setDialogOpen(false)}
|
||||
onSubmit={handleSubmit}
|
||||
title={editing ? 'Fahrzeugtyp bearbeiten' : 'Neuer Fahrzeugtyp'}
|
||||
isSubmitting={isSaving}
|
||||
>
|
||||
<TextField
|
||||
label="Name *"
|
||||
fullWidth
|
||||
@@ -471,18 +470,7 @@ function FahrzeugTypenSettings() {
|
||||
onChange={(e) => setForm((f) => ({ ...f, icon: e.target.value }))}
|
||||
placeholder="z.B. fire_truck"
|
||||
/>
|
||||
</DialogContent>
|
||||
<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>
|
||||
</FormDialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import {
|
||||
Box, Typography, Paper, Chip, IconButton, Button, Dialog, DialogTitle,
|
||||
DialogContent, DialogActions, TextField, MenuItem, Select, FormControl,
|
||||
Box, Typography, Paper, Chip, IconButton, Button,
|
||||
TextField, MenuItem, Select, FormControl,
|
||||
InputLabel, Divider, CircularProgress, Autocomplete, Grid, Card, CardContent,
|
||||
List, ListItem, ListItemIcon, ListItemText, ListItemSecondaryAction,
|
||||
} from '@mui/material';
|
||||
@@ -20,6 +20,7 @@ import { usePermissionContext } from '../contexts/PermissionContext';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { issuesApi } from '../services/issues';
|
||||
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) ──
|
||||
|
||||
@@ -260,21 +261,16 @@ export default function IssueDetail() {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<Box sx={{ p: 3 }}>
|
||||
{/* Header */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
|
||||
<IconButton onClick={() => navigate('/issues')}>
|
||||
<ArrowBack />
|
||||
</IconButton>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="h5">
|
||||
{formatIssueId(issue)} — {issue.titel}
|
||||
</Typography>
|
||||
</Box>
|
||||
<PageHeader
|
||||
title={`${formatIssueId(issue)} — ${issue.titel}`}
|
||||
backTo="/issues"
|
||||
actions={
|
||||
<Chip
|
||||
label={getStatusLabel(statuses, issue.status)}
|
||||
color={getStatusColor(statuses, issue.status)}
|
||||
/>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Info cards */}
|
||||
<Grid container spacing={2} sx={{ mb: 3 }}>
|
||||
@@ -559,9 +555,15 @@ export default function IssueDetail() {
|
||||
</Box>
|
||||
|
||||
{/* Reopen Dialog */}
|
||||
<Dialog open={reopenOpen} onClose={() => setReopenOpen(false)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Issue wiedereröffnen</DialogTitle>
|
||||
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: '20px !important' }}>
|
||||
<FormDialog
|
||||
open={reopenOpen}
|
||||
onClose={() => setReopenOpen(false)}
|
||||
onSubmit={handleReopen}
|
||||
title="Issue wiedereröffnen"
|
||||
submitLabel="Wiedereröffnen"
|
||||
isSubmitting={updateMut.isPending}
|
||||
maxWidth="sm"
|
||||
>
|
||||
<TextField
|
||||
label="Kommentar (Pflicht)"
|
||||
required
|
||||
@@ -572,37 +574,19 @@ export default function IssueDetail() {
|
||||
onChange={(e) => setReopenComment(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setReopenOpen(false)}>Abbrechen</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={!reopenComment.trim() || updateMut.isPending}
|
||||
onClick={handleReopen}
|
||||
>
|
||||
Wiedereröffnen
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</FormDialog>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog open={deleteOpen} onClose={() => setDeleteOpen(false)} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>Issue löschen</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography>Soll dieses Issue wirklich gelöscht werden?</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDeleteOpen(false)}>Abbrechen</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="error"
|
||||
disabled={deleteMut.isPending}
|
||||
onClick={() => deleteMut.mutate()}
|
||||
>
|
||||
Löschen
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
<ConfirmDialog
|
||||
open={deleteOpen}
|
||||
onClose={() => setDeleteOpen(false)}
|
||||
onConfirm={() => deleteMut.mutate()}
|
||||
title="Issue löschen"
|
||||
message="Soll dieses Issue wirklich gelöscht werden?"
|
||||
confirmLabel="Löschen"
|
||||
confirmColor="error"
|
||||
isLoading={deleteMut.isPending}
|
||||
/>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Box, Typography, Paper, Button, TextField, MenuItem, Select, FormControl,
|
||||
InputLabel, IconButton, Grid, Collapse,
|
||||
Button, TextField, MenuItem, Select, FormControl,
|
||||
InputLabel, Grid, Collapse,
|
||||
} 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 { useNavigate } from 'react-router-dom';
|
||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
import { PageHeader, FormLayout } from '../components/templates';
|
||||
import { useNotification } from '../contexts/NotificationContext';
|
||||
import { issuesApi } from '../services/issues';
|
||||
import type { CreateIssuePayload } from '../types/issue.types';
|
||||
@@ -52,16 +53,27 @@ export default function IssueNeu() {
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
{/* Header */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||
<IconButton onClick={() => navigate('/issues')}>
|
||||
<ArrowBack />
|
||||
</IconButton>
|
||||
<Typography variant="h5">Neues Issue</Typography>
|
||||
</Box>
|
||||
<PageHeader
|
||||
title="Neues Issue"
|
||||
breadcrumbs={[
|
||||
{ label: 'Issues', href: '/issues' },
|
||||
{ label: 'Neues Issue' },
|
||||
]}
|
||||
backTo="/issues"
|
||||
/>
|
||||
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<FormLayout
|
||||
actions={<>
|
||||
<Button onClick={() => navigate('/issues')}>Abbrechen</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={!form.titel.trim() || createMut.isPending}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
Erstellen
|
||||
</Button>
|
||||
</>}
|
||||
>
|
||||
<TextField
|
||||
label="Titel"
|
||||
required
|
||||
@@ -132,19 +144,7 @@ export default function IssueNeu() {
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1, mt: 1 }}>
|
||||
<Button onClick={() => navigate('/issues')}>Abbrechen</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={!form.titel.trim() || createMut.isPending}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
Erstellen
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
</FormLayout>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import {
|
||||
Box, Tab, Tabs, Typography, Table, TableBody, TableCell, TableContainer,
|
||||
TableHead, TableRow, Paper, Chip, IconButton, Button, Dialog, DialogTitle,
|
||||
DialogContent, DialogActions, TextField, MenuItem, Select, FormControl,
|
||||
TableHead, TableRow, Paper, Chip, IconButton, Button, TextField, MenuItem, Select, FormControl,
|
||||
InputLabel, CircularProgress, FormControlLabel, Switch,
|
||||
Autocomplete, ToggleButtonGroup, ToggleButton,
|
||||
} from '@mui/material';
|
||||
// Note: Table/TableBody/etc still needed for IssueSettings tables
|
||||
import {
|
||||
Add as AddIcon, Delete as DeleteIcon,
|
||||
BugReport, FiberNew, HelpOutline,
|
||||
@@ -17,6 +17,8 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
import ChatAwareFab from '../components/shared/ChatAwareFab';
|
||||
import { DataTable, FormDialog } from '../components/templates';
|
||||
import type { Column } from '../components/templates';
|
||||
import { useNotification } from '../contexts/NotificationContext';
|
||||
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
@@ -91,68 +93,45 @@ function IssueTable({
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (issues.length === 0) {
|
||||
return (
|
||||
<Typography color="text.secondary" sx={{ py: 4, textAlign: 'center' }}>
|
||||
Keine Issues vorhanden
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
const columns: Column<Issue>[] = [
|
||||
{ key: 'id', label: 'ID', width: 80, render: (row) => formatIssueId(row) },
|
||||
{
|
||||
key: 'titel', label: 'Titel', render: (row) => (
|
||||
<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 (
|
||||
<TableContainer component={Paper} variant="outlined">
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>ID</TableCell>
|
||||
<TableCell>Titel</TableCell>
|
||||
<TableCell>Typ</TableCell>
|
||||
<TableCell>Priorität</TableCell>
|
||||
<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)}
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={issues}
|
||||
rowKey={(row) => row.id}
|
||||
onRowClick={(row) => navigate(`/issues/${row.id}`)}
|
||||
emptyMessage="Keine Issues vorhanden"
|
||||
searchEnabled={false}
|
||||
paginationEnabled={false}
|
||||
/>
|
||||
</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>
|
||||
|
||||
{/* ──── Create Status Dialog ──── */}
|
||||
<Dialog open={statusCreateOpen} onClose={() => setStatusCreateOpen(false)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Neuer Status</DialogTitle>
|
||||
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: '20px !important' }}>
|
||||
<FormDialog
|
||||
open={statusCreateOpen}
|
||||
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="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>
|
||||
@@ -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_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" />
|
||||
</DialogContent>
|
||||
<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>
|
||||
</FormDialog>
|
||||
|
||||
{/* ──── Create Priority Dialog ──── */}
|
||||
<Dialog open={prioCreateOpen} onClose={() => setPrioCreateOpen(false)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Neue Priorität</DialogTitle>
|
||||
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: '20px !important' }}>
|
||||
<FormDialog
|
||||
open={prioCreateOpen}
|
||||
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="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>
|
||||
<TextField label="Sortierung" type="number" value={prioCreateData.sort_order ?? 0} onChange={(e) => setPrioCreateData({ ...prioCreateData, sort_order: parseInt(e.target.value) || 0 })} />
|
||||
</DialogContent>
|
||||
<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>
|
||||
</FormDialog>
|
||||
|
||||
{/* ──── Create Kategorie Dialog ──── */}
|
||||
<Dialog open={typeCreateOpen} onClose={() => setTypeCreateOpen(false)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Neue Kategorie</DialogTitle>
|
||||
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: '20px !important' }}>
|
||||
<FormDialog
|
||||
open={typeCreateOpen}
|
||||
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 />
|
||||
<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>
|
||||
<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" />
|
||||
<TextField label="Sortierung" type="number" value={typeCreateData.sort_order ?? 0} onChange={(e) => setTypeCreateData({ ...typeCreateData, sort_order: parseInt(e.target.value) || 0 })} />
|
||||
</DialogContent>
|
||||
<DialogActions><Button onClick={() => setTypeCreateOpen(false)}>Abbrechen</Button><Button variant="contained" disabled={!typeCreateData.name?.trim() || createTypeMut.isPending} onClick={() => createTypeMut.mutate(typeCreateData)}>Erstellen</Button></DialogActions>
|
||||
</Dialog>
|
||||
</FormDialog>
|
||||
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -5,17 +5,9 @@ import {
|
||||
Paper,
|
||||
Button,
|
||||
TextField,
|
||||
IconButton,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Grid,
|
||||
Card,
|
||||
CardContent,
|
||||
Skeleton,
|
||||
} 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 { useParams, useNavigate } from 'react-router-dom';
|
||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
@@ -23,6 +15,7 @@ import { useNotification } from '../contexts/NotificationContext';
|
||||
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||
import { bestellungApi } from '../services/bestellung';
|
||||
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: '' };
|
||||
|
||||
@@ -151,11 +144,9 @@ export default function LieferantDetail() {
|
||||
}
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
|
||||
<IconButton onClick={() => navigate('/bestellungen?tab=1')}><ArrowBack /></IconButton>
|
||||
<PageHeader title="" backTo="/bestellungen?tab=1" />
|
||||
<Skeleton width={300} height={40} />
|
||||
</Box>
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Paper sx={{ p: 3, mt: 2 }}>
|
||||
<Skeleton height={40} />
|
||||
<Skeleton height={40} />
|
||||
<Skeleton height={40} />
|
||||
@@ -169,13 +160,16 @@ export default function LieferantDetail() {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
{/* ── Header ── */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
|
||||
<IconButton onClick={() => navigate('/bestellungen?tab=1')}>
|
||||
<ArrowBack />
|
||||
</IconButton>
|
||||
<Typography variant="h4" sx={{ flexGrow: 1 }}>
|
||||
{isNew ? 'Neuer Lieferant' : vendor!.name}
|
||||
</Typography>
|
||||
<PageHeader
|
||||
title={isNew ? 'Neuer Lieferant' : vendor!.name}
|
||||
breadcrumbs={[
|
||||
{ label: 'Bestellungen', href: '/bestellungen' },
|
||||
{ label: 'Lieferanten', href: '/bestellungen?tab=1' },
|
||||
{ label: isNew ? 'Neu' : vendor!.name },
|
||||
]}
|
||||
backTo="/bestellungen?tab=1"
|
||||
actions={
|
||||
<>
|
||||
{!isNew && canManage && !editMode && (
|
||||
<>
|
||||
<Button startIcon={<EditIcon />} onClick={() => setEditMode(true)}>
|
||||
@@ -201,7 +195,9 @@ export default function LieferantDetail() {
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* ── Content ── */}
|
||||
{editMode ? (
|
||||
@@ -249,73 +245,31 @@ export default function LieferantDetail() {
|
||||
</Box>
|
||||
</Paper>
|
||||
) : (
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} sm={6} md={4}>
|
||||
<Card variant="outlined"><CardContent>
|
||||
<Typography variant="caption" color="text.secondary">Name</Typography>
|
||||
<Typography>{vendor!.name}</Typography>
|
||||
</CardContent></Card>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6} md={4}>
|
||||
<Card variant="outlined"><CardContent>
|
||||
<Typography variant="caption" color="text.secondary">Kontakt</Typography>
|
||||
<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>
|
||||
<InfoGrid
|
||||
columns={2}
|
||||
fields={[
|
||||
{ label: 'Name', value: vendor!.name },
|
||||
{ label: 'Kontakt', value: vendor!.kontakt_name || '–' },
|
||||
{ label: 'E-Mail', value: vendor!.email ? <a href={`mailto:${vendor!.email}`}>{vendor!.email}</a> : '–' },
|
||||
{ label: 'Telefon', value: vendor!.telefon || '–' },
|
||||
{ label: 'Website', value: vendor!.website ? <a href={ensureUrl(vendor!.website)} target="_blank" rel="noopener noreferrer">{vendor!.website}</a> : '–' },
|
||||
{ label: 'Adresse', value: vendor!.adresse || '–' },
|
||||
...(vendor!.notizen ? [{ label: 'Notizen', value: <Typography sx={{ whiteSpace: 'pre-wrap' }}>{vendor!.notizen}</Typography>, fullWidth: true }] : []),
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Delete Dialog ── */}
|
||||
<Dialog open={deleteDialogOpen} onClose={() => setDeleteDialogOpen(false)}>
|
||||
<DialogTitle>Lieferant löschen</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography>
|
||||
Soll der Lieferant <strong>{vendor?.name}</strong> wirklich gelöscht werden?
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDeleteDialogOpen(false)}>Abbrechen</Button>
|
||||
<Button color="error" variant="contained" onClick={() => deleteVendor.mutate()} disabled={deleteVendor.isPending}>
|
||||
Löschen
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
<ConfirmDialog
|
||||
open={deleteDialogOpen}
|
||||
onClose={() => setDeleteDialogOpen(false)}
|
||||
onConfirm={() => deleteVendor.mutate()}
|
||||
title="Lieferant löschen"
|
||||
message={<Typography>Soll der Lieferant <strong>{vendor?.name}</strong> wirklich gelöscht werden?</Typography>}
|
||||
confirmLabel="Löschen"
|
||||
confirmColor="error"
|
||||
isLoading={deleteVendor.isPending}
|
||||
/>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ import {
|
||||
UpdateMemberProfileData,
|
||||
} 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 { UntersuchungErgebnisLabel } from '../types/atemschutz.types';
|
||||
|
||||
@@ -77,23 +78,6 @@ function useCurrentUserId(): string | undefined {
|
||||
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
|
||||
// ----------------------------------------------------------------
|
||||
@@ -432,14 +416,10 @@ function MitgliedDetail() {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<Container maxWidth="lg">
|
||||
{/* Back button */}
|
||||
<Button
|
||||
variant="text"
|
||||
onClick={() => navigate('/mitglieder')}
|
||||
sx={{ mb: 2 }}
|
||||
>
|
||||
← Mitgliederliste
|
||||
</Button>
|
||||
<PageHeader
|
||||
title={displayName}
|
||||
backTo="/mitglieder"
|
||||
/>
|
||||
|
||||
{/* Header card */}
|
||||
<Card sx={{ mb: 3 }}>
|
||||
@@ -459,10 +439,10 @@ function MitgliedDetail() {
|
||||
{displayName}
|
||||
</Typography>
|
||||
{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}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
@@ -694,7 +674,7 @@ function MitgliedDetail() {
|
||||
/>
|
||||
<FieldRow label="Status" value={
|
||||
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
|
||||
} />
|
||||
<FieldRow
|
||||
|
||||
@@ -9,19 +9,12 @@ import {
|
||||
Avatar,
|
||||
Tooltip,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
OutlinedInput,
|
||||
SelectChangeEvent,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TablePagination,
|
||||
Paper,
|
||||
} from '@mui/material';
|
||||
@@ -33,6 +26,7 @@ import {
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
import ChatAwareFab from '../components/shared/ChatAwareFab';
|
||||
import { DataTable, StatusChip } from '../components/templates';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||
import { membersService } from '../services/members';
|
||||
@@ -281,138 +275,60 @@ function Mitglieder() {
|
||||
|
||||
{/* Table */}
|
||||
<Paper sx={{ width: '100%', overflow: 'hidden' }}>
|
||||
<TableContainer>
|
||||
<Table stickyHeader size="small" aria-label="Mitgliederliste">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell sx={{ width: 56 }}>Foto</TableCell>
|
||||
<TableCell>Name</TableCell>
|
||||
<TableCell>Stundenbuchnr.</TableCell>
|
||||
<TableCell>Dienstgrad</TableCell>
|
||||
<TableCell>Funktion</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell>Eintrittsdatum</TableCell>
|
||||
<TableCell>Telefon</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} align="center" sx={{ py: 6 }}>
|
||||
<CircularProgress size={32} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : members.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} align="center" sx={{ py: 8 }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1 }}>
|
||||
<PeopleIcon sx={{ fontSize: 48, color: 'text.disabled' }} />
|
||||
<Typography color="text.secondary">
|
||||
Keine Mitglieder gefunden.
|
||||
</Typography>
|
||||
</Box>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
members.map((member) => {
|
||||
<DataTable<MemberListItem>
|
||||
columns={[
|
||||
{ key: 'profile_picture_url', label: 'Foto', width: 56, sortable: false, searchable: false, render: (member) => {
|
||||
const displayName = getMemberDisplayName(member);
|
||||
const initials = [member.given_name?.[0], member.family_name?.[0]]
|
||||
.filter(Boolean)
|
||||
.join('')
|
||||
.toUpperCase() || member.email[0].toUpperCase();
|
||||
|
||||
.filter(Boolean).join('').toUpperCase() || member.email[0].toUpperCase();
|
||||
return (
|
||||
<TableRow
|
||||
key={member.id}
|
||||
hover
|
||||
onClick={() => handleRowClick(member.id)}
|
||||
sx={{ cursor: 'pointer' }}
|
||||
aria-label={`Mitglied ${displayName} öffnen`}
|
||||
>
|
||||
{/* Avatar */}
|
||||
<TableCell>
|
||||
<Avatar
|
||||
src={member.profile_picture_url ?? undefined}
|
||||
alt={displayName}
|
||||
sx={{ width: 36, height: 36, fontSize: '0.875rem' }}
|
||||
>
|
||||
<Avatar src={member.profile_picture_url ?? undefined} alt={displayName} 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>
|
||||
);
|
||||
}},
|
||||
{ key: 'family_name', label: 'Name', render: (member) => {
|
||||
const displayName = getMemberDisplayName(member);
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="body2" fontWeight={500}>{displayName}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{member.email}</Typography>
|
||||
</Box>
|
||||
);
|
||||
}},
|
||||
{ key: 'fdisk_standesbuch_nr', label: 'Stundenbuchnr.', render: (member) => member.fdisk_standesbuch_nr ?? '—' },
|
||||
{ key: 'dienstgrad', label: 'Dienstgrad', render: (member) => member.dienstgrad
|
||||
? <Chip label={member.dienstgrad} size="small" variant="outlined" />
|
||||
: <Typography variant="body2" color="text.secondary">—</Typography>
|
||||
},
|
||||
{ key: 'funktion', label: 'Funktion', sortable: false, render: (member) => (
|
||||
<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" />
|
||||
))
|
||||
? 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
|
||||
)},
|
||||
{ key: 'status', label: 'Status', render: (member) => member.status
|
||||
? <StatusChip status={member.status} labelMap={STATUS_LABELS} colorMap={STATUS_COLORS} />
|
||||
: <Typography variant="body2" color="text.secondary">—</Typography>
|
||||
},
|
||||
{ key: 'eintrittsdatum', label: 'Eintrittsdatum', render: (member) => 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>
|
||||
: '—'
|
||||
},
|
||||
{ key: 'telefon_mobil', label: 'Telefon', render: (member) => formatPhone(member.telefon_mobil) },
|
||||
]}
|
||||
data={members}
|
||||
rowKey={(member) => member.id}
|
||||
onRowClick={(member) => handleRowClick(member.id)}
|
||||
isLoading={loading}
|
||||
emptyMessage="Keine Mitglieder gefunden."
|
||||
emptyIcon={<PeopleIcon sx={{ fontSize: 40, mb: 1, opacity: 0.5 }} />}
|
||||
searchEnabled={false}
|
||||
paginationEnabled={false}
|
||||
stickyHeader
|
||||
/>
|
||||
|
||||
<TablePagination
|
||||
component="div"
|
||||
|
||||
@@ -38,7 +38,6 @@ import {
|
||||
useSortable,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
import { useThemeMode } from '../contexts/ThemeContext';
|
||||
import { preferencesApi } from '../services/settings';
|
||||
|
||||
@@ -12,7 +12,7 @@ import type {
|
||||
ErinnerungFormData,
|
||||
BestellungHistorie,
|
||||
} from '../types/bestellung.types';
|
||||
import type { AusruestungArtikel, AusruestungEigenschaft } from '../types/ausruestungsanfrage.types';
|
||||
import type { AusruestungArtikel } from '../types/ausruestungsanfrage.types';
|
||||
|
||||
export const bestellungApi = {
|
||||
// ── Vendors ──
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
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
|
||||
const primaryRed = {
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user