refactor external orders

This commit is contained in:
Matthias Hochmeister
2026-03-25 14:55:25 +01:00
parent 5add6590e5
commit 0bb2feaba2
5 changed files with 305 additions and 229 deletions

View File

@@ -1,6 +1,12 @@
import { useState, useEffect, useMemo } from 'react';
import {
Accordion,
AccordionSummary,
AccordionDetails,
Box,
Card,
CardContent,
Grid,
Tab,
Tabs,
Typography,
@@ -16,12 +22,10 @@ import {
Checkbox,
FormControlLabel,
FormGroup,
Popover,
Badge,
LinearProgress,
Divider,
} from '@mui/material';
import { Add as AddIcon, FilterList as FilterListIcon } from '@mui/icons-material';
import { Add as AddIcon, ExpandMore as ExpandMoreIcon, FilterList as FilterListIcon } from '@mui/icons-material';
import { useQuery } from '@tanstack/react-query';
import { useNavigate, useSearchParams } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout';
@@ -78,6 +82,7 @@ export default function Bestellungen() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { hasPermission } = usePermissionContext();
const canManageVendors = hasPermission('bestellungen:manage_vendors');
// Tab from URL
const [tab, setTab] = useState(() => {
@@ -90,10 +95,7 @@ export default function Bestellungen() {
}, [searchParams]);
// ── Filter state ──
const [filterAnchor, setFilterAnchor] = useState<HTMLElement | null>(null);
const [selectedVendors, setSelectedVendors] = useState<Set<string> | null>(null); // null = all
const [selectedOrderers, setSelectedOrderers] = useState<Set<string> | null>(null);
const [selectedStatuses, setSelectedStatuses] = useState<Set<BestellungStatus>>(
() => new Set(ALL_STATUSES.filter((s) => !DEFAULT_EXCLUDED_STATUSES.includes(s)))
);
@@ -118,14 +120,6 @@ export default function Bestellungen() {
return Array.from(map.entries()).sort((a, b) => a[1].localeCompare(b[1]));
}, [orders]);
const uniqueOrderers = useMemo(() => {
const map = new Map<string, string>();
orders.forEach((o) => {
if (o.besteller_name) map.set(o.besteller_id ?? o.besteller_name, o.besteller_name);
});
return Array.from(map.entries()).sort((a, b) => a[1].localeCompare(b[1]));
}, [orders]);
// ── Filtered orders ──
const filteredOrders = useMemo(() => {
return orders.filter((o) => {
@@ -136,28 +130,21 @@ export default function Bestellungen() {
const key = String(o.lieferant_id ?? o.lieferant_name ?? '');
if (!selectedVendors.has(key)) return false;
}
// Orderer filter (null = all selected)
if (selectedOrderers !== null) {
const key = o.besteller_id ?? o.besteller_name ?? '';
if (!selectedOrderers.has(key)) return false;
}
return true;
});
}, [orders, selectedStatuses, selectedVendors, selectedOrderers]);
}, [orders, selectedStatuses, selectedVendors]);
// ── Active filter count ──
const activeFilterCount = useMemo(() => {
let count = 0;
if (selectedStatuses.size !== ALL_STATUSES.length - DEFAULT_EXCLUDED_STATUSES.length) count++;
if (selectedVendors !== null) count++;
if (selectedOrderers !== null) count++;
return count;
}, [selectedStatuses, selectedVendors, selectedOrderers]);
}, [selectedStatuses, selectedVendors]);
// ── Filter handlers ──
function resetFilters() {
setSelectedVendors(null);
setSelectedOrderers(null);
setSelectedStatuses(new Set(ALL_STATUSES.filter((s) => !DEFAULT_EXCLUDED_STATUSES.includes(s))));
}
@@ -187,29 +174,10 @@ export default function Bestellungen() {
});
}
function toggleOrderer(key: string) {
setSelectedOrderers((prev) => {
if (prev === null) {
const allKeys = new Set(uniqueOrderers.map(([k]) => k));
allKeys.delete(key);
return allKeys;
}
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
if (next.size === uniqueOrderers.length) return null;
return next;
});
}
function isVendorSelected(key: string) {
return selectedVendors === null || selectedVendors.has(key);
}
function isOrdererSelected(key: string) {
return selectedOrderers === null || selectedOrderers.has(key);
}
// ── Render ──
return (
@@ -219,23 +187,84 @@ export default function Bestellungen() {
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
<Tabs value={tab} onChange={(_e, v) => { setTab(v); navigate(`/bestellungen?tab=${v}`, { replace: true }); }} variant="scrollable" scrollButtons="auto">
<Tab label="Bestellungen" />
<Tab label="Lieferanten" />
{canManageVendors && <Tab label="Lieferanten" />}
</Tabs>
</Box>
{/* ── Tab 0: Orders ── */}
<TabPanel value={tab} index={0}>
{/* ── Summary Cards ── */}
<Grid container spacing={2} sx={{ mb: 3 }}>
{[
{ label: 'Noch nicht bestellt', count: orders.filter(o => o.status === 'entwurf' || o.status === 'erstellt').length, color: 'text.secondary' },
{ label: 'Bestellt', count: orders.filter(o => o.status === 'bestellt').length, color: 'primary.main' },
{ label: 'Teillieferung', count: orders.filter(o => o.status === 'teillieferung').length, color: 'warning.main' },
{ label: 'Vollständig', count: orders.filter(o => o.status === 'vollstaendig').length, color: 'success.main' },
{ label: 'Gesamt', count: orders.length, color: 'text.primary' },
].map(({ label, count, color }) => (
<Grid item xs={6} sm={4} md={2} key={label}>
<Card variant="outlined">
<CardContent sx={{ py: 1.5, '&:last-child': { pb: 1.5 } }}>
<Typography variant="h4" sx={{ color, fontWeight: 700 }}>{count}</Typography>
<Typography variant="caption" color="text.secondary">{label}</Typography>
</CardContent>
</Card>
</Grid>
))}
</Grid>
{/* ── Filter ── */}
<Accordion defaultExpanded={false} disableGutters sx={{ mb: 2, '&:before': { display: 'none' }, border: 1, borderColor: 'divider', borderRadius: 1 }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<FilterListIcon fontSize="small" />
<Typography variant="body2">Filter</Typography>
{activeFilterCount > 0 && (
<Chip label={activeFilterCount} size="small" color="primary" sx={{ height: 18, fontSize: '0.7rem' }} />
)}
</Box>
</AccordionSummary>
<AccordionDetails>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{/* Status */}
<Box>
<Typography variant="subtitle2" sx={{ mb: 0.5 }}>Status</Typography>
<FormGroup row>
{ALL_STATUSES.map((s) => (
<FormControlLabel
key={s}
control={<Checkbox size="small" checked={selectedStatuses.has(s)} onChange={() => toggleStatus(s)} />}
label={BESTELLUNG_STATUS_LABELS[s]}
/>
))}
</FormGroup>
</Box>
<Divider />
{/* Vendor */}
{uniqueVendors.length > 0 && (
<Box>
<Typography variant="subtitle2" sx={{ mb: 0.5 }}>Lieferant</Typography>
<FormGroup>
{uniqueVendors.map(([key, label]) => (
<FormControlLabel
key={key}
control={<Checkbox size="small" checked={isVendorSelected(key)} onChange={() => toggleVendor(key)} />}
label={label}
/>
))}
</FormGroup>
</Box>
)}
<Divider />
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button size="small" onClick={resetFilters}>Zurücksetzen</Button>
</Box>
</Box>
</AccordionDetails>
</Accordion>
{/* Active filter info */}
<Box sx={{ mb: 2, display: 'flex', gap: 2, alignItems: 'center' }}>
<Badge badgeContent={activeFilterCount} color="primary">
<Button
variant="outlined"
size="small"
startIcon={<FilterListIcon />}
onClick={(e) => setFilterAnchor(e.currentTarget)}
>
Filter
</Button>
</Badge>
{activeFilterCount > 0 && (
<Button size="small" onClick={resetFilters}>Zurücksetzen</Button>
)}
@@ -244,69 +273,6 @@ export default function Bestellungen() {
</Typography>
</Box>
{/* Filter Popover */}
<Popover
open={!!filterAnchor}
anchorEl={filterAnchor}
onClose={() => setFilterAnchor(null)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
transformOrigin={{ vertical: 'top', horizontal: 'left' }}
slotProps={{ paper: { sx: { p: 2, maxWidth: 480, maxHeight: '70vh', overflow: 'auto' } } }}
>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{/* Status */}
<Box>
<Typography variant="subtitle2" sx={{ mb: 0.5 }}>Status</Typography>
<FormGroup row>
{ALL_STATUSES.map((s) => (
<FormControlLabel
key={s}
control={<Checkbox size="small" checked={selectedStatuses.has(s)} onChange={() => toggleStatus(s)} />}
label={BESTELLUNG_STATUS_LABELS[s]}
/>
))}
</FormGroup>
</Box>
<Divider />
{/* Vendor */}
{uniqueVendors.length > 0 && (
<Box>
<Typography variant="subtitle2" sx={{ mb: 0.5 }}>Lieferant</Typography>
<FormGroup>
{uniqueVendors.map(([key, label]) => (
<FormControlLabel
key={key}
control={<Checkbox size="small" checked={isVendorSelected(key)} onChange={() => toggleVendor(key)} />}
label={label}
/>
))}
</FormGroup>
</Box>
)}
{uniqueVendors.length > 0 && <Divider />}
{/* Orderer */}
{uniqueOrderers.length > 0 && (
<Box>
<Typography variant="subtitle2" sx={{ mb: 0.5 }}>Besteller</Typography>
<FormGroup>
{uniqueOrderers.map(([key, label]) => (
<FormControlLabel
key={key}
control={<Checkbox size="small" checked={isOrdererSelected(key)} onChange={() => toggleOrderer(key)} />}
label={label}
/>
))}
</FormGroup>
</Box>
)}
<Divider />
<Button size="small" onClick={resetFilters}>Zurücksetzen</Button>
</Box>
</Popover>
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
@@ -360,7 +326,8 @@ export default function Bestellungen() {
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<LinearProgress
variant="determinate"
value={deliveryPct}
value={Math.min(deliveryPct, 100)}
color={deliveryPct >= 100 ? 'success' : 'primary'}
sx={{ flexGrow: 1, height: 6, borderRadius: 3 }}
/>
<Typography variant="caption" sx={{ whiteSpace: 'nowrap' }}>
@@ -386,6 +353,7 @@ export default function Bestellungen() {
</TabPanel>
{/* ── Tab 1: Vendors ── */}
{canManageVendors && (
<TabPanel value={tab} index={1}>
<TableContainer component={Paper}>
<Table size="small">
@@ -417,7 +385,7 @@ export default function Bestellungen() {
<TableCell>{v.telefon || ''}</TableCell>
<TableCell>
{v.website ? (
<a href={v.website} target="_blank" rel="noopener noreferrer" onClick={(e) => e.stopPropagation()}>{v.website}</a>
<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>
@@ -427,12 +395,11 @@ export default function Bestellungen() {
</Table>
</TableContainer>
{hasPermission('bestellungen:manage_vendors') && (
<ChatAwareFab onClick={() => navigate('/bestellungen/lieferanten/neu')} aria-label="Lieferant hinzufügen">
<AddIcon />
</ChatAwareFab>
)}
</TabPanel>
)}
</DashboardLayout>
);
}