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

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