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

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