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

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

View File

@@ -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),

View File

@@ -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
);
{!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" />
)
)},
{ 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={getValidityColor(item.untersuchung_gueltig_bis, item.untersuchung_tage_rest, 90)} fontWeight={500}>
{formatDate(item.untersuchung_gueltig_bis)}
</Typography>
</Tooltip>
)},
{ key: 'leistungstest_gueltig_bis', label: 'Leistungstest gültig bis', render: (item) => (
<Tooltip title={
item.leistungstest_tage_rest !== null
? item.leistungstest_tage_rest < 0
? `Seit ${Math.abs(item.leistungstest_tage_rest)} Tagen abgelaufen`
: `Noch ${item.leistungstest_tage_rest} Tage gültig`
: 'Kein Leistungstest eingetragen'
}>
<Typography variant="body2" color={getValidityColor(item.leistungstest_gueltig_bis, item.leistungstest_tage_rest, 30)} fontWeight={500}>
{formatDate(item.leistungstest_gueltig_bis)}
</Typography>
</Tooltip>
)},
{ key: 'einsatzbereit', label: 'Status', align: 'center', render: (item) => (
<Chip
label={item.einsatzbereit ? 'Einsatzbereit' : 'Nicht einsatzbereit'}
color={item.einsatzbereit ? 'success' : 'error'}
size="small"
variant="filled"
/>
)},
];
return (
<TableRow key={item.id} hover>
<TableCell>
<Typography variant="body2" fontWeight={500}>
{getDisplayName(item)}
</Typography>
</TableCell>
<TableCell align="center">
{item.atemschutz_lehrgang ? (
<Tooltip title={item.lehrgang_datum ? `Lehrgang am ${formatDate(item.lehrgang_datum)}` : 'Lehrgang absolviert'}>
<Check color="success" fontSize="small" />
</Tooltip>
) : (
<Close color="disabled" fontSize="small" />
)}
</TableCell>
<TableCell>
<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}>
{formatDate(item.untersuchung_gueltig_bis)}
</Typography>
</Tooltip>
</TableCell>
<TableCell>
<Tooltip
title={
item.leistungstest_tage_rest !== null
? item.leistungstest_tage_rest < 0
? `Seit ${Math.abs(item.leistungstest_tage_rest)} Tagen abgelaufen`
: `Noch ${item.leistungstest_tage_rest} Tage gültig`
: 'Kein Leistungstest eingetragen'
}
>
<Typography variant="body2" color={leistungstestColor} fontWeight={500}>
{formatDate(item.leistungstest_gueltig_bis)}
</Typography>
</Tooltip>
</TableCell>
<TableCell align="center">
<Chip
label={item.einsatzbereit ? 'Einsatzbereit' : 'Nicht einsatzbereit'}
color={item.einsatzbereit ? 'success' : 'error'}
size="small"
variant="filled"
/>
</TableCell>
{canWrite && (
<TableCell align="right">
<Tooltip title="Bearbeiten">
<Button
size="small"
onClick={() => handleOpenEdit(item)}
sx={{ minWidth: 'auto', mr: 0.5 }}
>
<Edit fontSize="small" />
</Button>
</Tooltip>
<Tooltip title="Löschen">
<Button
size="small"
color="error"
onClick={() => setDeleteId(item.id)}
sx={{ minWidth: 'auto' }}
>
<Delete fontSize="small" />
</Button>
</Tooltip>
</TableCell>
)}
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
)}
if (canWrite) {
columns.push({
key: 'actions', label: 'Aktionen', align: 'right', sortable: false, searchable: false, render: (item) => (
<>
<Tooltip title="Bearbeiten">
<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={(e) => { e.stopPropagation(); setDeleteId(item.id); }} sx={{ minWidth: 'auto' }}>
<Delete fontSize="small" />
</Button>
</Tooltip>
</>
),
});
}
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}
/>
);
})()}
{/* 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>
);

View File

@@ -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 &quot;{deletingTyp?.name}&quot; 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 &quot;{deletingTyp?.name}&quot; wirklich löschen? Geräte, denen dieser Typ zugeordnet ist, verlieren die Zuordnung.</>}
confirmLabel="Löschen"
confirmColor="error"
isLoading={deleteMutation.isPending}
/>
</Box>
);
}

View File

@@ -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,133 +742,89 @@ 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 }}>
<Chip
icon={STATUS_ICONS[equipment.status]}
label={AusruestungStatusLabel[equipment.status]}
color={STATUS_CHIP_COLOR[equipment.status]}
/>
{canWrite && (
<Tooltip title="Gerät bearbeiten">
<IconButton
size="small"
onClick={() => navigate(`/ausruestung/${equipment.id}/bearbeiten`)}
aria-label="Gerät bearbeiten"
>
<Edit />
</IconButton>
</Tooltip>
)}
{isAdmin && (
<Tooltip title="Gerät löschen">
<IconButton
size="small"
color="error"
onClick={() => setDeleteDialogOpen(true)}
aria-label="Gerät löschen"
>
<DeleteOutline />
</IconButton>
</Tooltip>
)}
</Box>
</Box>
<Box sx={{ borderBottom: 1, borderColor: 'divider', mt: 2 }}>
<Tabs
value={activeTab}
onChange={(_, v) => setActiveTab(v)}
aria-label="Ausrüstung Detailansicht"
variant="scrollable"
scrollButtons="auto"
>
<Tab label="Übersicht" />
<Tab
label={
hasOverdue
? <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
Wartung <Warning color="error" fontSize="small" />
</Box>
: 'Wartung'
}
/>
</Tabs>
</Box>
<TabPanel value={activeTab} index={0}>
<UebersichtTab
equipment={equipment}
onStatusUpdated={fetchEquipment}
canChangeStatus={canWrite}
canWrite={canWrite}
/>
</TabPanel>
<TabPanel value={activeTab} index={1}>
<WartungTab
equipmentId={equipment.id}
wartungslog={equipment.wartungslog ?? []}
onAdded={fetchEquipment}
canWrite={canManageEquipmentMaintenance}
/>
</TabPanel>
<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]}
color={STATUS_CHIP_COLOR[equipment.status]}
/>
{canWrite && (
<Tooltip title="Gerät bearbeiten">
<IconButton
size="small"
onClick={() => navigate(`/ausruestung/${equipment.id}/bearbeiten`)}
aria-label="Gerät bearbeiten"
>
<Edit />
</IconButton>
</Tooltip>
)}
{isAdmin && (
<Tooltip title="Gerät löschen">
<IconButton
size="small"
color="error"
onClick={() => setDeleteDialogOpen(true)}
aria-label="Gerät löschen"
>
<DeleteOutline />
</IconButton>
</Tooltip>
)}
</Box>
}
/>
{/* Delete confirmation dialog */}
<Dialog open={deleteDialogOpen} onClose={() => !deleteLoading && setDeleteDialogOpen(false)}>
<DialogTitle>Gerät löschen</DialogTitle>
<DialogContent>
<DialogContentText>
Möchten Sie &apos;{equipment.bezeichnung}&apos; 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>
);

View File

@@ -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>}

View File

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

View File

@@ -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 }));

View File

@@ -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,40 +670,44 @@ 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>
{canExport && !editMode && (
<Tooltip title={bestellung.status === 'entwurf' || bestellung.status === 'wartet_auf_genehmigung' ? 'Export erst nach Genehmigung verfügbar' : 'PDF Export'}>
<span>
<IconButton
onClick={generateBestellungDetailPdf}
color="primary"
disabled={bestellung.status === 'entwurf' || bestellung.status === 'wartet_auf_genehmigung'}
>
<PdfIcon />
</IconButton>
</span>
</Tooltip>
)}
{canCreate && !editMode && (
<Button startIcon={<EditIcon />} onClick={enterEditMode}>Bearbeiten</Button>
)}
{editMode && (
<>
<Button variant="contained" startIcon={<SaveIcon />} onClick={handleSaveAll} disabled={isSavingAll}>
Speichern
</Button>
<Button onClick={cancelEditMode} disabled={isSavingAll}>Abbrechen</Button>
</>
)}
<Chip
label={BESTELLUNG_STATUS_LABELS[bestellung.status]}
color={BESTELLUNG_STATUS_COLORS[bestellung.status]}
/>
</Box>
<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>
<IconButton
onClick={generateBestellungDetailPdf}
color="primary"
disabled={bestellung.status === 'entwurf' || bestellung.status === 'wartet_auf_genehmigung'}
>
<PdfIcon />
</IconButton>
</span>
</Tooltip>
)}
{canCreate && !editMode && (
<Button startIcon={<EditIcon />} onClick={enterEditMode}>Bearbeiten</Button>
)}
{editMode && (
<>
<Button variant="contained" startIcon={<SaveIcon />} onClick={handleSaveAll} disabled={isSavingAll}>
Speichern
</Button>
<Button onClick={cancelEditMode} disabled={isSavingAll}>Abbrechen</Button>
</>
)}
<StatusChip
status={bestellung.status}
labelMap={BESTELLUNG_STATUS_LABELS}
colorMap={BESTELLUNG_STATUS_COLORS}
size="medium"
/>
</Box>
}
/>
{/* ── Info Cards ── */}
{editMode ? (
@@ -1344,73 +1344,68 @@ export default function BestellungDetail() {
{/* ══════════════════════════════════════════════════════════════════════ */}
{/* Status Confirmation */}
<Dialog open={statusConfirmTarget != null} onClose={() => { setStatusConfirmTarget(null); setStatusForce(false); }}>
<DialogTitle>Status ändern{statusForce ? ' (manuell)' : ''}</DialogTitle>
<DialogContent>
<Typography>
Status von <strong>{BESTELLUNG_STATUS_LABELS[bestellung.status]}</strong> auf{' '}
<strong>{statusConfirmTarget ? BESTELLUNG_STATUS_LABELS[statusConfirmTarget] : ''}</strong> ändern?
</Typography>
{statusForce && (
<Typography variant="body2" color="warning.main" sx={{ mt: 1 }}>
Dies ist ein manueller Statusübergang außerhalb des normalen Ablaufs.
<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?
</Typography>
)}
{statusConfirmTarget && ['lieferung_pruefen', 'abgeschlossen'].includes(statusConfirmTarget) && !allCostsEntered && (
<Alert severity="warning" sx={{ mt: 2 }}>
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>
{statusForce && (
<Typography variant="body2" color="warning.main" sx={{ mt: 1 }}>
Dies ist ein manueller Statusübergang außerhalb des normalen Ablaufs.
</Typography>
)}
{statusConfirmTarget && ['lieferung_pruefen', 'abgeschlossen'].includes(statusConfirmTarget) && !allCostsEntered && (
<Alert severity="warning" sx={{ mt: 2 }}>
Nicht alle Positionen haben einen Einzelpreis. Bitte prüfen Sie die Kosten, bevor Sie den Status ändern.
</Alert>
)}
</>
}
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>
);
}

View File

@@ -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>
);
}

View File

@@ -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);
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>
</Box>
) : ''}
</TableCell>
<TableCell>{formatDate(o.erstellt_am)}</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</TableContainer>
<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 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>
) : '';
}},
{ 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 ? (
<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>
<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>
) : '' },
]}
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 />

View File

@@ -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 }}>

View File

@@ -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}>

View File

@@ -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>
);
}

View File

@@ -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 ?? ''} &middot; {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>

View File

@@ -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>
);
}

View File

@@ -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,114 +390,62 @@ 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' }}
/>
</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>
<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}
/>
<TablePagination
component="div"
count={total}
page={page}
onPageChange={handlePageChange}
rowsPerPage={rowsPerPage}
onRowsPerPageChange={handleRowsPerPageChange}
rowsPerPageOptions={[10, 25, 50, 100]}
labelRowsPerPage="Einträge pro Seite:"
labelDisplayedRows={({ from, to, count }) =>
`${from}${to} von ${count !== -1 ? count : `mehr als ${to}`}`
}
/>
</Paper>
<TablePagination
component="div"
count={total}
page={page}
onPageChange={handlePageChange}
rowsPerPage={rowsPerPage}
onRowsPerPageChange={handleRowsPerPageChange}
rowsPerPageOptions={[10, 25, 50, 100]}
labelRowsPerPage="Einträge pro Seite:"
labelDisplayedRows={({ from, to, count }) =>
`${from}${to} von ${count !== -1 ? count : `mehr als ${to}`}`
}
/>
{/* Create dialog */}
<CreateEinsatzDialog

View File

@@ -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,94 +280,75 @@ 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}>
<Tooltip title="PDF exportieren (Vorschau)">
<Button
<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"
startIcon={<PictureAsPdf />}
onClick={handleExportPdf}
size="small"
>
PDF Export
</Button>
</Tooltip>
{canWrite && !editing ? (
<Button
variant="contained"
startIcon={<Edit />}
onClick={() => setEditing(true)}
size="small"
>
Bearbeiten
</Button>
) : canWrite && editing ? (
<>
/>
<Tooltip title="PDF exportieren (Vorschau)">
<Button
variant="outlined"
startIcon={<Cancel />}
onClick={handleCancelEdit}
startIcon={<PictureAsPdf />}
onClick={handleExportPdf}
size="small"
disabled={saving}
>
Abbrechen
PDF Export
</Button>
</Tooltip>
{canWrite && !editing ? (
<Button
variant="contained"
color="success"
startIcon={<Save />}
onClick={handleSaveBericht}
startIcon={<Edit />}
onClick={() => setEditing(true)}
size="small"
disabled={saving}
>
{saving ? 'Speichere...' : 'Speichern'}
Bearbeiten
</Button>
</>
) : null}
</Stack>
</Box>
) : canWrite && editing ? (
<>
<Button
variant="outlined"
startIcon={<Cancel />}
onClick={handleCancelEdit}
size="small"
disabled={saving}
>
Abbrechen
</Button>
<Button
variant="contained"
color="success"
startIcon={<Save />}
onClick={handleSaveBericht}
size="small"
disabled={saving}
>
{saving ? 'Speichere...' : 'Speichern'}
</Button>
</>
) : null}
</Stack>
}
/>
{/* HEADER */}
<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">
{einsatz.einsatz_stichwort}
</Typography>
)}
</Box>
<Typography variant="h4" fontWeight={700}>
Einsatz {einsatz.einsatz_nr}
{einsatz.einsatz_stichwort && (
<Typography variant="h6" color="text.secondary" sx={{ mb: 2, mt: -2 }}>
{einsatz.einsatz_stichwort}
</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 */}

View File

@@ -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,130 +941,105 @@ 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>
<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]}
color={STATUS_CHIP_COLOR[vehicle.status]}
/>
{isAdmin && (
<Tooltip title="Fahrzeug bearbeiten">
<IconButton
size="small"
onClick={() => navigate(`/fahrzeuge/${vehicle.id}/bearbeiten`)}
aria-label="Fahrzeug bearbeiten"
>
<Edit />
</IconButton>
</Tooltip>
)}
</Typography>
{vehicle.amtliches_kennzeichen && (
<Typography variant="subtitle1" color="text.secondary">
{vehicle.amtliches_kennzeichen}
</Typography>
)}
</Box>
<Box sx={{ ml: 'auto', display: 'flex', alignItems: 'center', gap: 1 }}>
<Chip
icon={STATUS_ICONS[vehicle.status]}
label={FahrzeugStatusLabel[vehicle.status]}
color={STATUS_CHIP_COLOR[vehicle.status]}
/>
{isAdmin && (
<Tooltip title="Fahrzeug bearbeiten">
<IconButton
size="small"
onClick={() => navigate(`/fahrzeuge/${vehicle.id}/bearbeiten`)}
aria-label="Fahrzeug bearbeiten"
>
<Edit />
</IconButton>
</Tooltip>
)}
{isAdmin && (
<Tooltip title="Fahrzeug löschen">
<IconButton
size="small"
color="error"
onClick={() => setDeleteDialogOpen(true)}
aria-label="Fahrzeug löschen"
>
<DeleteOutline />
</IconButton>
</Tooltip>
)}
</Box>
</Box>
<Box sx={{ borderBottom: 1, borderColor: 'divider', mt: 2 }}>
<Tabs
value={activeTab}
onChange={(_, v) => setActiveTab(v)}
aria-label="Fahrzeug Detailansicht"
variant="scrollable"
scrollButtons="auto"
>
<Tab label="Übersicht" />
<Tab
label={
hasOverdue
? <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
Wartung <Warning color="error" fontSize="small" />
</Box>
: 'Wartung'
}
/>
<Tab label="Einsätze" />
<Tab label={`Ausrüstung${vehicleEquipment.length > 0 ? ` (${vehicleEquipment.length})` : ''}`} />
{hasPermission('checklisten:view') && <Tab label="Checklisten" />}
</Tabs>
</Box>
<TabPanel value={activeTab} index={0}>
<UebersichtTab
vehicle={vehicle}
onStatusUpdated={fetchVehicle}
canChangeStatus={canChangeStatus}
canEdit={hasPermission('fahrzeuge:edit')}
/>
</TabPanel>
<TabPanel value={activeTab} index={1}>
<WartungTab
fahrzeugId={vehicle.id}
wartungslog={vehicle.wartungslog ?? []}
onAdded={fetchVehicle}
canWrite={canManageMaintenance}
/>
</TabPanel>
<TabPanel value={activeTab} index={2}>
<Box sx={{ textAlign: 'center', py: 8 }}>
<LocalFireDepartment sx={{ fontSize: 64, color: 'text.disabled', mb: 2 }} />
<Typography variant="h6" color="text.secondary">
Einsatzhistorie
</Typography>
<Typography variant="body2" color="text.disabled">
Die Einsatzverknüpfung wird in Tier 2 (Einsatzverwaltung) implementiert.
</Typography>
</Box>
</TabPanel>
<TabPanel value={activeTab} index={3}>
<AusruestungTab equipment={vehicleEquipment} vehicleId={vehicle.id} />
</TabPanel>
{hasPermission('checklisten:view') && (
<TabPanel value={activeTab} index={4}>
<FahrzeugChecklistTab fahrzeugId={vehicle.id} />
</TabPanel>
)}
{isAdmin && (
<Tooltip title="Fahrzeug löschen">
<IconButton
size="small"
color="error"
onClick={() => setDeleteDialogOpen(true)}
aria-label="Fahrzeug löschen"
>
<DeleteOutline />
</IconButton>
</Tooltip>
)}
</Box>
}
/>
{/* Delete confirmation dialog */}
<Dialog open={deleteDialogOpen} onClose={() => !deleteLoading && setDeleteDialogOpen(false)}>

View File

@@ -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>}

View File

@@ -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>
);
}

View File

@@ -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>
<Chip
label={getStatusLabel(statuses, issue.status)}
color={getStatusColor(statuses, issue.status)}
/>
</Box>
<PageHeader
title={`${formatIssueId(issue)}${issue.titel}`}
backTo="/issues"
actions={
<Chip
label={getStatusLabel(statuses, issue.status)}
color={getStatusColor(statuses, issue.status)}
/>
}
/>
{/* Info cards */}
<Grid container spacing={2} sx={{ mb: 3 }}>
@@ -559,50 +555,38 @@ 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' }}>
<TextField
label="Kommentar (Pflicht)"
required
multiline
rows={3}
fullWidth
value={reopenComment}
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
open={reopenOpen}
onClose={() => setReopenOpen(false)}
onSubmit={handleReopen}
title="Issue wiedereröffnen"
submitLabel="Wiedereröffnen"
isSubmitting={updateMut.isPending}
maxWidth="sm"
>
<TextField
label="Kommentar (Pflicht)"
required
multiline
rows={3}
fullWidth
value={reopenComment}
onChange={(e) => setReopenComment(e.target.value)}
autoFocus
/>
</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>
);
}

View File

@@ -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,99 +53,98 @@ 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
fullWidth
value={form.titel}
onChange={(e) => setForm({ ...form, titel: e.target.value })}
autoFocus
/>
<Collapse in={showDescription} unmountOnExit>
<TextField
label="Titel"
required
label="Beschreibung"
multiline
rows={4}
fullWidth
value={form.titel}
onChange={(e) => setForm({ ...form, titel: e.target.value })}
autoFocus
value={form.beschreibung || ''}
onChange={(e) => setForm({ ...form, beschreibung: e.target.value })}
/>
</Collapse>
{!showDescription && (
<Button
size="small"
startIcon={<AddIcon />}
onClick={() => setShowDescription(true)}
sx={{ alignSelf: 'flex-start' }}
>
Beschreibung hinzufuegen
</Button>
)}
<Collapse in={showDescription} unmountOnExit>
<TextField
label="Beschreibung"
multiline
rows={4}
fullWidth
value={form.beschreibung || ''}
onChange={(e) => setForm({ ...form, beschreibung: e.target.value })}
/>
</Collapse>
{!showDescription && (
<Button
size="small"
startIcon={<AddIcon />}
onClick={() => setShowDescription(true)}
sx={{ alignSelf: 'flex-start' }}
>
Beschreibung hinzufuegen
</Button>
)}
<Grid container spacing={2}>
<Grid item xs={12} sm={6}>
<FormControl fullWidth>
<InputLabel>Typ</InputLabel>
<Select
value={form.typ_id ?? defaultTypId ?? ''}
label="Typ"
onChange={(e) => setForm({ ...form, typ_id: Number(e.target.value) })}
>
{types.filter(t => t.aktiv).map(t => (
<MenuItem key={t.id} value={t.id}>{t.name}</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid item xs={12} sm={6}>
<FormControl fullWidth>
<InputLabel>Prioritaet</InputLabel>
<Select
value={form.prioritaet || defaultPriority}
label="Prioritaet"
onChange={(e) => setForm({ ...form, prioritaet: e.target.value })}
>
{priorities.filter(p => p.aktiv).map(p => (
<MenuItem key={p.schluessel} value={p.schluessel}>{p.bezeichnung}</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
label="Fällig am"
type="date"
fullWidth
value={form.faellig_am || ''}
onChange={(e) => setForm({ ...form, faellig_am: e.target.value || null })}
InputLabelProps={{ shrink: true }}
/>
</Grid>
<Grid container spacing={2}>
<Grid item xs={12} sm={6}>
<FormControl fullWidth>
<InputLabel>Typ</InputLabel>
<Select
value={form.typ_id ?? defaultTypId ?? ''}
label="Typ"
onChange={(e) => setForm({ ...form, typ_id: Number(e.target.value) })}
>
{types.filter(t => t.aktiv).map(t => (
<MenuItem key={t.id} value={t.id}>{t.name}</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<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>
<Grid item xs={12} sm={6}>
<FormControl fullWidth>
<InputLabel>Prioritaet</InputLabel>
<Select
value={form.prioritaet || defaultPriority}
label="Prioritaet"
onChange={(e) => setForm({ ...form, prioritaet: e.target.value })}
>
{priorities.filter(p => p.aktiv).map(p => (
<MenuItem key={p.schluessel} value={p.schluessel}>{p.bezeichnung}</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
label="Fällig am"
type="date"
fullWidth
value={form.faellig_am || ''}
onChange={(e) => setForm({ ...form, faellig_am: e.target.value || null })}
InputLabelProps={{ shrink: true }}
/>
</Grid>
</Grid>
</FormLayout>
</DashboardLayout>
);
}

View File

@@ -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)}
/>
</TableCell>
<TableCell>{issue.erstellt_von_name || '-'}</TableCell>
<TableCell>{issue.zugewiesen_an_name || '-'}</TableCell>
<TableCell>{formatDate(issue.created_at)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<DataTable
columns={columns}
data={issues}
rowKey={(row) => row.id}
onRowClick={(row) => navigate(`/issues/${row.id}`)}
emptyMessage="Keine Issues vorhanden"
searchEnabled={false}
paginationEnabled={false}
/>
);
}
@@ -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>
);

View File

@@ -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>
<Skeleton width={300} height={40} />
</Box>
<Paper sx={{ p: 3 }}>
<PageHeader title="" backTo="/bestellungen?tab=1" />
<Skeleton width={300} height={40} />
<Paper sx={{ p: 3, mt: 2 }}>
<Skeleton height={40} />
<Skeleton height={40} />
<Skeleton height={40} />
@@ -169,39 +160,44 @@ 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>
{!isNew && canManage && !editMode && (
<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={
<>
<Button startIcon={<EditIcon />} onClick={() => setEditMode(true)}>
Bearbeiten
</Button>
<Button startIcon={<DeleteIcon />} color="error" onClick={() => setDeleteDialogOpen(true)}>
Löschen
</Button>
{!isNew && canManage && !editMode && (
<>
<Button startIcon={<EditIcon />} onClick={() => setEditMode(true)}>
Bearbeiten
</Button>
<Button startIcon={<DeleteIcon />} color="error" onClick={() => setDeleteDialogOpen(true)}>
Löschen
</Button>
</>
)}
{editMode && (
<>
<Button
variant="contained"
startIcon={<SaveIcon />}
onClick={handleSave}
disabled={!form.name.trim() || isSaving}
>
Speichern
</Button>
<Button startIcon={<CloseIcon />} onClick={handleCancel}>
Abbrechen
</Button>
</>
)}
</>
)}
{editMode && (
<>
<Button
variant="contained"
startIcon={<SaveIcon />}
onClick={handleSave}
disabled={!form.name.trim() || isSaving}
>
Speichern
</Button>
<Button startIcon={<CloseIcon />} onClick={handleCancel}>
Abbrechen
</Button>
</>
)}
</Box>
}
/>
{/* ── Content ── */}
{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>
);
}

View File

@@ -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

View File

@@ -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) => {
const displayName = getMemberDisplayName(member);
const initials = [member.given_name?.[0], member.family_name?.[0]]
.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' }}
>
{initials}
</Avatar>
</TableCell>
{/* Name + email */}
<TableCell>
<Typography variant="body2" fontWeight={500}>
{displayName}
</Typography>
<Typography variant="caption" color="text.secondary">
{member.email}
</Typography>
</TableCell>
{/* Stundenbuchnr */}
<TableCell>
<Typography variant="body2">
{member.fdisk_standesbuch_nr ?? '—'}
</Typography>
</TableCell>
{/* Dienstgrad */}
<TableCell>
{member.dienstgrad ? (
<Chip label={member.dienstgrad} size="small" variant="outlined" />
) : (
<Typography variant="body2" color="text.secondary"></Typography>
)}
</TableCell>
{/* Funktion(en) */}
<TableCell>
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{Array.isArray(member.funktion) && member.funktion.length > 0
? member.funktion.map((f) => (
<Chip key={f} label={f} size="small" variant="outlined" color="secondary" />
))
: <Typography variant="body2" color="text.secondary"></Typography>
}
</Box>
</TableCell>
{/* Status */}
<TableCell>
{member.status ? (
<Chip
label={STATUS_LABELS[member.status]}
size="small"
color={STATUS_COLORS[member.status]}
/>
) : (
<Typography variant="body2" color="text.secondary"></Typography>
)}
</TableCell>
{/* Eintrittsdatum */}
<TableCell>
<Typography variant="body2">
{member.eintrittsdatum
? new Date(member.eintrittsdatum).toLocaleDateString('de-AT')
: '—'}
</Typography>
</TableCell>
{/* Telefon */}
<TableCell>
<Typography variant="body2">
{formatPhone(member.telefon_mobil)}
</Typography>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</TableContainer>
<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();
return (
<Avatar src={member.profile_picture_url ?? undefined} alt={displayName} sx={{ width: 36, height: 36, fontSize: '0.875rem' }}>
{initials}
</Avatar>
);
}},
{ 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" />)
: <Typography variant="body2" color="text.secondary"></Typography>
}
</Box>
)},
{ 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')
: '—'
},
{ 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"

View File

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