refactor external orders
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user