feat(frontend): implement unified design system with 17 reusable template components, skeleton loading states, and golden-ratio-based layouts
This commit is contained in:
@@ -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 />
|
||||
|
||||
Reference in New Issue
Block a user