Files
dashfront/src/components/manifest/OrderList.jsx
2026-10-28 00:08:45 +01:00

850 lines
30 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { PureComponent, createRef } from 'react';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Chip from '@mui/material/Chip';
import CircularProgress from '@mui/material/CircularProgress';
import Divider from '@mui/material/Divider';
import FormControlLabel from '@mui/material/FormControlLabel';
import IconButton from '@mui/material/IconButton';
import ListItemButton from '@mui/material/ListItemButton';
import Typography from '@mui/material/Typography';
import Checkbox from '@mui/material/Checkbox';
import Switch from '@mui/material/Switch';
import Skeleton from '@mui/material/Skeleton';
import Tooltip from '@mui/material/Tooltip';
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
import ViewModuleIcon from '@mui/icons-material/ViewModule';
import PaymentsOutlinedIcon from '@mui/icons-material/PaymentsOutlined';
import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined';
import { enqueueSnackbar } from 'notistack';
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
import FillSpace from 'dashgui/FillSpace';
import { PanelTile, PanelTileGrid } from 'dashgui/panels';
import BackendError from '../BackendError';
import orderAutomat, {
CHUNK_SIZE,
isOrderPaid,
isOrderDelivered,
} from '../../automats/orderAutomat';
import companyAutomat from '../../automats/companyAutomat';
import { order as orderApi } from '../../api';
import { backendErrorFrom } from '../../utils/backendError';
import { notifyError } from '../../utils/notify';
import { formatPrice } from '../../utils/price';
import { getMaxDocumentNumber } from '../../utils/documentNumber';
import PartyFilterPanel, { PartyFilterButton, PartyFilterChip } from './PartyFilterPanel';
import withNavigate from '../withNavigate';
import {
formatDate,
loadParties,
manifestConfig,
numberDisplay,
partyDisplayName,
} from './manifestConfig';
// MARK: pure helpers
function orderGrossTotal(lines) {
if (!Array.isArray(lines)) return 0;
return lines.reduce((acc, line) => {
const net = (Number(line.amount) || 0) * (Number(line.net) || 0) * (1 - (Number(line.rebate) || 0) / 100);
return acc + net * (1 + (Number(line.taxrate) || 0) / 100);
}, 0);
}
const cfg = manifestConfig('order');
// Dummy order generator using the active sequence scheme with max digits for layout testing
export function createDummyTestOrder() {
const maxNumber = getMaxDocumentNumber('order');
return {
id: 999999,
number: maxNumber,
externalNumber: 'REF-999999',
date: '2030-10-30T12:00:00Z',
customerName: 'Kunde',
positions: 999,
grossTotal: 30000.90,
};
}
const rowGridSx = {
display: 'grid',
gridTemplateColumns: 'max-content max-content 1fr max-content max-content max-content max-content auto',
gridTemplateRows: '0px 1fr',
rowGap: 0,
columnGap: 1.5,
alignItems: 'center',
width: '100%',
boxSizing: 'border-box',
};
const ellipsisSx = { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' };
const measuringCellSx = {
visibility: 'hidden',
height: 0,
boxSizing: 'border-box',
pointerEvents: 'none',
textTransform: 'none',
letterSpacing: 'normal',
};
const headerCellSx = {
color: 'text.secondary',
typography: 'caption',
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.04em',
userSelect: 'none',
};
function renderInvisibleDummyRow(dummy) {
const d = dummy || createDummyTestOrder();
return (
<>
<Box aria-hidden sx={{ gridColumn: 1, gridRow: 1, whiteSpace: 'nowrap', ...measuringCellSx }}>
<Typography variant="body1" sx={{ fontWeight: 600, fontFamily: 'monospace' }}>{d.number}</Typography>
<Typography variant="body2" sx={{ fontFamily: 'monospace' }}>{d.externalNumber || 'Nr. / Ref.'}</Typography>
</Box>
<Box aria-hidden sx={{ gridColumn: 2, gridRow: 1, whiteSpace: 'nowrap', ...measuringCellSx }}>
<Typography variant="body2">{d.date ? formatDate(d.date) : '–'}</Typography>
</Box>
<Box aria-hidden sx={{ gridColumn: 3, gridRow: 1, whiteSpace: 'nowrap', ...measuringCellSx }}>
<Typography variant="body1">{d.customerName}</Typography>
</Box>
<Box aria-hidden sx={{ gridColumn: 4, gridRow: 1, display: 'flex', justifyContent: 'center', ...measuringCellSx }}>
<Checkbox size="small" sx={{ p: 0.5 }} />
</Box>
<Box aria-hidden sx={{ gridColumn: 5, gridRow: 1, display: 'flex', justifyContent: 'center', ...measuringCellSx }}>
<Checkbox size="small" sx={{ p: 0.5 }} />
</Box>
<Box aria-hidden sx={{ gridColumn: 6, gridRow: 1, display: 'flex', justifyContent: 'center', ...measuringCellSx }}>
<Chip size="small" label={d.positions} sx={{ height: 24, '& .MuiChip-label': { px: 0.5 } }} />
</Box>
<Box aria-hidden sx={{ gridColumn: 7, gridRow: 1, whiteSpace: 'nowrap', display: 'flex', justifyContent: 'flex-end', ...measuringCellSx }}>
<Typography variant="body2" sx={{ fontWeight: 600, fontVariantNumeric: 'tabular-nums' }}>
{formatPrice(d.grossTotal)}
</Typography>
</Box>
<Box aria-hidden sx={{ gridColumn: 8, gridRow: 1, display: 'flex', justifyContent: 'center', ...measuringCellSx }}>
<IconButton sx={{ width: 40, height: 40 }} />
</Box>
</>
);
}
/**
* Ambient context for `OrderRow`: FillSpace renders rows as bare elements
* (`<Row item index />`), so the list publishes callbacks and the party map
* here on every render.
*/
const rowContext = {
partiesById: {},
busyId: null,
onOpen: null,
onDelete: null,
onTogglePaid: null,
onToggleDelivered: null,
headerItem: null,
};
// MARK: <OrderRow/>
class OrderRow extends PureComponent {
partyName(partyId) {
if (partyId == null) return '–';
const party = rowContext.partiesById[partyId];
return partyDisplayName('order', party) || `Kunde #${partyId}`;
}
render() {
const { item: order, disabled } = this.props;
const dummy = this.props.headerItem || rowContext.headerItem;
// Data-free probe (FillSpace measures the row height with `item` undefined).
if (!order) {
if (disabled) {
return (
<ListItemButton disabled divider sx={{ ...rowGridSx, height: 64, minHeight: 64, maxHeight: 64, px: 2, py: 0, opacity: 0, pointerEvents: 'none' }}>
<Typography variant="body2">&nbsp;</Typography>
</ListItemButton>
);
}
return (
<ListItemButton disabled divider sx={{ ...rowGridSx, height: 64, minHeight: 64, maxHeight: 64, px: 2, py: 0 }}>
{renderInvisibleDummyRow(dummy)}
<Box sx={{ gridColumn: 1, gridRow: 2 }}><Skeleton variant="text" width="90%" height={20} /></Box>
<Box sx={{ gridColumn: 2, gridRow: 2 }}><Skeleton variant="text" width="90%" height={20} /></Box>
<Box sx={{ gridColumn: 3, gridRow: 2 }}><Skeleton variant="text" width="60%" height={20} /></Box>
<Box sx={{ gridColumn: 4, gridRow: 2, display: 'flex', justifyContent: 'center' }}><Skeleton variant="circular" width={20} height={20} /></Box>
<Box sx={{ gridColumn: 5, gridRow: 2, display: 'flex', justifyContent: 'center' }}><Skeleton variant="circular" width={20} height={20} /></Box>
<Box sx={{ gridColumn: 6, gridRow: 2, display: 'flex', justifyContent: 'center' }}><Skeleton variant="rounded" width={28} height={20} /></Box>
<Box sx={{ gridColumn: 7, gridRow: 2, display: 'flex', justifyContent: 'flex-end' }}><Skeleton variant="text" width="80%" height={20} /></Box>
<Box sx={{ gridColumn: 8, gridRow: 2 }} />
</ListItemButton>
);
}
const { busyId, onOpen, onDelete, onTogglePaid, onToggleDelivered } = rowContext;
const lines = order.lines ?? [];
const gross = orderGrossTotal(lines);
const paid = isOrderPaid(order);
const delivered = isOrderDelivered(order);
return (
<ListItemButton
divider
sx={{ ...rowGridSx, height: 64, minHeight: 64, maxHeight: 64, px: 2, py: 0 }}
onClick={disabled ? undefined : () => onOpen?.(order)}
disabled={disabled}
>
{renderInvisibleDummyRow(dummy)}
<Box sx={{ gridColumn: 1, gridRow: 2, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
<Typography variant="body1" sx={{ fontWeight: 600, fontFamily: 'monospace', ...ellipsisSx }}>
{order.number}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ fontFamily: 'monospace', ...ellipsisSx }}>
{numberDisplay(order.externalNumber)}
</Typography>
</Box>
<Box sx={{ gridColumn: 2, gridRow: 2, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
<Typography variant="body2" color="text.secondary" sx={ellipsisSx}>
{order.date ? formatDate(order.date) : '–'}
</Typography>
</Box>
<Box sx={{ gridColumn: 3, gridRow: 2, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
<Typography variant="body1" sx={{ fontWeight: 500, ...ellipsisSx }}>
{this.partyName(order.entityId)}
</Typography>
</Box>
<Box sx={{ gridColumn: 4, gridRow: 2, display: 'flex', justifyContent: 'center' }}>
<Checkbox
size="small"
checked={paid}
color="success"
onClick={(e) => e.stopPropagation()}
onChange={(e) => onTogglePaid?.(order, e.target.checked)}
slotProps={{ input: { 'aria-label': `Auftrag ${order.number} bezahlt` } }}
sx={{ p: 0.5 }}
/>
</Box>
<Box sx={{ gridColumn: 5, gridRow: 2, display: 'flex', justifyContent: 'center' }}>
<Checkbox
size="small"
checked={delivered}
color="primary"
onClick={(e) => e.stopPropagation()}
onChange={(e) => onToggleDelivered?.(order, e.target.checked)}
slotProps={{ input: { 'aria-label': `Auftrag ${order.number} geliefert` } }}
sx={{ p: 0.5 }}
/>
</Box>
<Box sx={{ gridColumn: 6, gridRow: 2, display: 'flex', justifyContent: 'center' }}>
<Chip
size="small"
label={lines.length}
title={`${lines.length} Positionen`}
sx={{
height: 24,
fontSize: '0.75rem',
fontWeight: 600,
fontVariantNumeric: 'tabular-nums',
bgcolor: 'action.hover',
color: lines.length > 0 ? 'text.primary' : 'text.disabled',
'& .MuiChip-label': { px: 0.5 },
}}
/>
</Box>
<Box sx={{ gridColumn: 7, gridRow: 2, display: 'flex', justifyContent: 'flex-end' }}>
<Typography
variant="body2"
sx={{ fontWeight: 600, fontVariantNumeric: 'tabular-nums', textAlign: 'right', whiteSpace: 'nowrap' }}
>
{gross > 0 ? formatPrice(gross) : '—'}
</Typography>
</Box>
<Box sx={{ gridColumn: 8, gridRow: 2, display: 'flex', justifyContent: 'center' }}>
<IconButton
aria-label={`„${order.number}“ löschen`}
disabled={busyId != null || disabled}
onClick={(e) => {
e.stopPropagation();
onDelete?.(order);
}}
onMouseDown={(e) => e.stopPropagation()}
sx={{ width: 40, height: 40 }}
>
<DeleteOutlinedIcon sx={{ fontSize: 24 }} />
</IconButton>
</Box>
</ListItemButton>
);
}
}
// MARK: <OrderSearchField/>
class OrderSearchField extends PureComponent {
constructor(props) {
super(props);
const s = orderAutomat.state;
const stats = orderAutomat.getChunkStats?.();
this.state = {
input: s.filter ?? '',
onlyUnpaid: s.onlyUnpaid ?? false,
onlyUndelivered: s.onlyUndelivered ?? false,
seekMonths: s.seekMonths ?? 0,
seekYears: s.seekYears ?? 0,
negativeSeekMonths: s.negativeSeekMonths ?? 0,
lruChunksCount: stats?.lruChunksCount || 0,
maxLruChunks: stats?.maxLruChunks || 50,
filterCachesCount: stats?.filterCaches?.length || 1,
};
}
componentDidMount() {
this.unsub = orderAutomat.subscribe(this, (state) => ({
input: state.filter ?? '',
onlyUnpaid: state.onlyUnpaid ?? false,
onlyUndelivered: state.onlyUndelivered ?? false,
seekMonths: state.seekMonths ?? 0,
seekYears: state.seekYears ?? 0,
negativeSeekMonths: state.negativeSeekMonths ?? 0,
}));
if (import.meta.env.DEV) {
this.unsubStats = orderAutomat.subscribeChunkStats?.((stats) => {
this.setState({
lruChunksCount: stats.lruChunksCount,
maxLruChunks: stats.maxLruChunks,
filterCachesCount: stats.filterCaches?.length || 1,
});
});
}
}
componentWillUnmount() {
this.unsub?.();
this.unsubStats?.();
}
render() {
const {
inputRef,
onCreate,
customerFilterOpen,
onToggleCustomerFilter,
selectedCustomer,
onClearCustomerFilter,
} = this.props;
const { input, onlyUnpaid, onlyUndelivered, seekMonths, seekYears } = this.state;
const monthActive = seekMonths > 0;
const monthLabel = !monthActive ? '-1 Month' : `-${seekMonths} Month${seekMonths > 1 ? 's' : ''}`;
const yearActive = seekYears > 0;
const yearLabel = !yearActive ? '-1 Year' : `-${seekYears} Year${seekYears > 1 ? 's' : ''}`;
return (
<PanelTileGrid>
<Box
ref={inputRef}
sx={{
gridColumn: { xs: '1 / -1', sm: 'span 2', md: 'span 2' },
display: 'flex',
flexDirection: 'column',
gap: 1.25,
pb: 2,
}}
>
{/* Top row: search field, customer filter button, active chip & create button */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
flexWrap: 'wrap',
}}
>
<SingleLineTextEdit
sx={{ flex: 1, minWidth: { xs: '100%', sm: 220 } }}
size="small"
search
clear
value={input}
onChange={(next) => orderAutomat.actions.filter(next)}
/>
<PartyFilterButton
title="Kunden"
open={customerFilterOpen}
onClick={onToggleCustomerFilter}
/>
{selectedCustomer && (
<PartyFilterChip
label={partyDisplayName('order', selectedCustomer)}
onDelete={onClearCustomerFilter}
/>
)}
<Button variant="contained" onClick={onCreate}>
{cfg.createButton}
</Button>
</Box>
{/* Filter Bar: "Nur ungezahlte", "Nur ungelieferte", Negativeseek (-1 Month, -1 Year, Reset) */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: 1.5,
}}
>
{/* Left: Unpaid & Undelivered toggles */}
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 1.5,
px: 1.5,
py: 0.25,
borderRadius: 2,
bgcolor: 'background.paper',
boxShadow: 1,
}}
>
<FormControlLabel
control={
<Switch
size="small"
color="warning"
checked={onlyUnpaid}
onChange={(e) => orderAutomat.actions.setOnlyUnpaid(e.target.checked)}
/>
}
label={
<Typography variant="body2" sx={{ fontWeight: onlyUnpaid ? 600 : 400, userSelect: 'none' }}>
Nur ungezahlte
</Typography>
}
sx={{ m: 0 }}
/>
<Divider orientation="vertical" flexItem sx={{ my: 0.5 }} />
<FormControlLabel
control={
<Switch
size="small"
color="primary"
checked={onlyUndelivered}
onChange={(e) => orderAutomat.actions.setOnlyUndelivered(e.target.checked)}
/>
}
label={
<Typography variant="body2" sx={{ fontWeight: onlyUndelivered ? 600 : 400, userSelect: 'none' }}>
Nur ungelieferte
</Typography>
}
sx={{ m: 0 }}
/>
</Box>
{/* Right: Negativeseek controls (-1 Month, -1 Year, Reset) */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.75,
px: 1.25,
py: 0.25,
borderRadius: 2,
bgcolor: 'background.paper',
boxShadow: 1,
}}
>
<Button
size="small"
variant={monthActive ? 'contained' : 'outlined'}
color={monthActive ? 'primary' : 'inherit'}
onClick={() => orderAutomat.actions.addSeekMonth()}
sx={{
minWidth: 88,
px: 1.25,
py: 0.25,
height: 28,
fontSize: '0.75rem',
fontWeight: 600,
textTransform: 'none',
boxSizing: 'border-box',
border: '1px solid',
borderColor: monthActive ? 'primary.main' : 'divider',
whiteSpace: 'nowrap',
}}
>
{monthLabel}
</Button>
<Button
size="small"
variant={yearActive ? 'contained' : 'outlined'}
color={yearActive ? 'primary' : 'inherit'}
onClick={() => orderAutomat.actions.addSeekYear()}
sx={{
minWidth: 78,
px: 1.25,
py: 0.25,
height: 28,
fontSize: '0.75rem',
fontWeight: 600,
textTransform: 'none',
boxSizing: 'border-box',
border: '1px solid',
borderColor: yearActive ? 'primary.main' : 'divider',
whiteSpace: 'nowrap',
}}
>
{yearLabel}
</Button>
<Button
size="small"
variant="text"
color="inherit"
disabled={!monthActive && !yearActive}
onClick={() => orderAutomat.actions.resetNegativeSeek()}
sx={{
minWidth: 'auto',
px: 1,
py: 0.25,
height: 28,
fontSize: '0.75rem',
fontWeight: 600,
textTransform: 'none',
}}
>
Reset
</Button>
</Box>
{import.meta.env.DEV && (
<Tooltip title={`Chunk-Cache-Monitor öffnen (Alt+Shift+C) — ${this.state.lruChunksCount || 0}/${this.state.maxLruChunks || 50} Chunks im RAM über ${this.state.filterCachesCount || 1} Filter`}>
<Chip
size="small"
icon={<ViewModuleIcon sx={{ fontSize: 16 }} />}
label={this.state.lruChunksCount > 0 ? `Chunk-Cache (${this.state.lruChunksCount}/${this.state.maxLruChunks || 50})` : 'Chunk-Cache'}
onClick={() => window.dispatchEvent(new CustomEvent('dash:toggle-chunk-monitor'))}
sx={{
cursor: 'pointer',
height: 28,
fontWeight: 600,
fontSize: '0.75rem',
bgcolor: 'background.paper',
boxShadow: 1,
'&:hover': { bgcolor: 'action.hover' },
}}
/>
</Tooltip>
)}
</Box>
</Box>
</Box>
</PanelTileGrid>
);
}
}
// MARK: <OrderList/>
export class OrderList extends PureComponent {
constructor(props) {
super(props);
this.headerItem = createDummyTestOrder();
// getItems() returns current items or null and triggers loading of the first chunk
this.state = {
items: orderAutomat.getItems(),
unfilteredTotal: orderAutomat.state.unfilteredTotal ?? 0,
filterKey: orderAutomat.getFilterKey?.() ?? '',
entityId: orderAutomat.state.entityId ?? null,
customerFilterOpen: false,
partiesById: {},
partiesList: [],
busyId: null,
};
this.searchRef = createRef();
this._cancelled = false;
this.handleRetry = this.handleRetry.bind(this);
this.handleCreate = this.handleCreate.bind(this);
this.handleOpen = this.handleOpen.bind(this);
this.handleDelete = this.handleDelete.bind(this);
this.handleTogglePaid = this.handleTogglePaid.bind(this);
this.handleToggleDelivered = this.handleToggleDelivered.bind(this);
this.handlePageChange = this.handlePageChange.bind(this);
this.handleToggleCustomerFilter = this.handleToggleCustomerFilter.bind(this);
this.handleSelectCustomer = this.handleSelectCustomer.bind(this);
}
componentDidMount() {
this._cancelled = false;
this.unsub = orderAutomat.subscribe(this, (state) => ({
items: state.items,
unfilteredTotal: state.unfilteredTotal ?? 0,
filterKey: orderAutomat.getFilterKey(state),
entityId: state.entityId ?? null,
}));
this.unsubNumber = companyAutomat.subscribe(
this,
() => {
this.headerItem = createDummyTestOrder();
this.forceUpdate();
},
['numbers'],
);
void companyAutomat.actions.ensureNumbersLoaded().catch(() => {});
void this.loadParties();
}
componentWillUnmount() {
this._cancelled = true;
this.unsub?.();
this.unsubNumber?.();
}
handleToggleCustomerFilter() {
this.setState((prev) => ({ customerFilterOpen: !prev.customerFilterOpen }));
}
handleSelectCustomer(entityId) {
orderAutomat.actions.setEntityId(entityId);
}
async loadParties() {
try {
const parties = await loadParties('order');
if (!this._cancelled) {
this.setState({
partiesById: cfg.partiesByIdFromList(parties),
partiesList: parties || [],
});
}
} catch {
// Non-fatal: names fall back to ids.
}
}
handlePageChange(page, meta) {
const pageSize = meta?.pageSize ?? 10;
const startIndex = (page - 1) * pageSize;
const items = this.state.items;
const maxIndex = items ? items.length - 1 : startIndex;
const endIndex = Math.min(maxIndex, startIndex + pageSize - 1);
const startChunk = Math.floor(startIndex / CHUNK_SIZE);
const endChunk = Math.floor(endIndex / CHUNK_SIZE);
orderAutomat.requireChunk(startChunk, endChunk);
}
handleRetry() {
void orderAutomat.actions.requireChunk(0, 0, { immediate: true }).catch(() => {});
}
handleCreate() {
this.props.navigate(cfg.createPath);
}
handleOpen(order) {
this.props.navigate(cfg.editPath(order.id));
}
async handleDelete(order) {
if (this.state.busyId != null) return;
if (!window.confirm(cfg.confirmDelete(order.number))) return;
this.setState({ busyId: order.id });
try {
await orderApi.delete(order.id);
await orderAutomat.actions.reload();
enqueueSnackbar(cfg.deletedMsg, { variant: 'success' });
} catch (err) {
notifyError(err);
} finally {
if (!this._cancelled) this.setState({ busyId: null });
}
}
async handleTogglePaid(order, checked) {
if (this.state.busyId != null) return;
const patch = { paid: checked, payed: checked, isPaid: checked };
orderAutomat.actions.patchOrder(order.id, patch);
try {
await orderApi.update(order.id, { paid: checked });
enqueueSnackbar(checked ? 'Auftrag als bezahlt markiert' : 'Auftrag als unbezahlt markiert', {
variant: 'info',
});
} catch (err) {
console.warn('[OrderList] update paid on backend failed:', err);
}
}
async handleToggleDelivered(order, checked) {
if (this.state.busyId != null) return;
const patch = { delivered: checked, isDelivered: checked };
orderAutomat.actions.patchOrder(order.id, patch);
try {
await orderApi.update(order.id, { delivered: checked });
enqueueSnackbar(checked ? 'Auftrag als geliefert markiert' : 'Auftrag als ungeliefert markiert', {
variant: 'info',
});
} catch (err) {
console.warn('[OrderList] update delivered on backend failed:', err);
}
}
render() {
const {
items,
unfilteredTotal,
filterKey,
partiesById,
partiesList,
entityId,
customerFilterOpen,
busyId,
} = this.state;
rowContext.partiesById = partiesById;
rowContext.busyId = busyId;
rowContext.onOpen = this.handleOpen;
rowContext.onDelete = this.handleDelete;
rowContext.onTogglePaid = this.handleTogglePaid;
rowContext.onToggleDelivered = this.handleToggleDelivered;
rowContext.headerItem = this.headerItem;
const customerCounts = {};
if (Array.isArray(items)) {
for (const ord of items) {
if (ord?.entityId) {
customerCounts[ord.entityId] = (customerCounts[ord.entityId] || 0) + 1;
}
}
}
const selectedCustomer = entityId ? (partiesById[entityId] || { id: entityId, name: `Kunde #${entityId}` }) : null;
const searchField = (
<OrderSearchField
inputRef={this.searchRef}
onCreate={this.handleCreate}
customerFilterOpen={customerFilterOpen}
onToggleCustomerFilter={this.handleToggleCustomerFilter}
selectedCustomer={selectedCustomer}
onClearCustomerFilter={() => this.handleSelectCustomer(null)}
/>
);
if (items === null && orderAutomat.state.status === 'failed') {
return (
<>
{searchField}
<PanelTileGrid>
<PanelTile wide={3} minHeight={0} color={cfg.listColor} sx={{ p: 2 }}>
<BackendError error={backendErrorFrom(orderAutomat.state.error)} onRetry={this.handleRetry} />
</PanelTile>
</PanelTileGrid>
</>
);
}
return (
<>
{searchField}
<Box sx={{ display: 'flex', gap: 2.5, alignItems: 'stretch' }}>
{customerFilterOpen && (
<PartyFilterPanel
title="Kunden"
parties={partiesList}
selectedId={entityId}
onSelect={this.handleSelectCustomer}
onClose={this.handleToggleCustomerFilter}
counts={customerCounts}
searchPlaceholder="Kunde suchen…"
/>
)}
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
<PanelTileGrid>
<PanelTile wide={3} minHeight={0} color={cfg.listColor} sx={{ pb: 0, position: 'relative' }}>
<FillSpace
aboveRef={[this.searchRef]}
reserveBottom={32}
minPageSize={8}
minHeight={0}
resetKey={filterKey}
items={items || []}
totalCount={items ? items.length : 0}
unfilteredTotal={unfilteredTotal}
headerItem={this.headerItem}
renderItem={OrderRow}
virtualScrollbar
header={
<Box
sx={{
...rowGridSx,
height: 36,
minHeight: 36,
px: 2,
borderBottom: '1px solid',
borderColor: 'divider',
}}
>
{renderInvisibleDummyRow(this.headerItem)}
<Box sx={{ gridColumn: 1, gridRow: 2, ...headerCellSx }}>Nr. / Ref.</Box>
<Box sx={{ gridColumn: 2, gridRow: 2, ...headerCellSx }}>Datum</Box>
<Box sx={{ gridColumn: 3, gridRow: 2, ...headerCellSx }}>Kunde</Box>
<Box sx={{ gridColumn: 4, gridRow: 2, display: 'flex', justifyContent: 'center', alignItems: 'center', ...headerCellSx }}>
<Tooltip title="Bezahlt">
<PaymentsOutlinedIcon sx={{ fontSize: 18 }} />
</Tooltip>
</Box>
<Box sx={{ gridColumn: 5, gridRow: 2, display: 'flex', justifyContent: 'center', alignItems: 'center', ...headerCellSx }}>
<Tooltip title="Geliefert">
<LocalShippingOutlinedIcon sx={{ fontSize: 18 }} />
</Tooltip>
</Box>
<Box sx={{ gridColumn: 6, gridRow: 2, display: 'flex', justifyContent: 'center', textAlign: 'center', ...headerCellSx }}>Pos.</Box>
<Box sx={{ gridColumn: 7, gridRow: 2, textAlign: 'right', ...headerCellSx }}>Gesamt</Box>
<Box sx={{ gridColumn: 8, gridRow: 2, ...headerCellSx }} />
</Box>
}
onPageChange={this.handlePageChange}
/>
{items === null && (
<Box
sx={{
position: 'absolute',
top: 52,
left: 0,
right: 0,
bottom: 32,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
pointerEvents: 'none',
zIndex: 2,
}}
>
<CircularProgress size={32} />
</Box>
)}
</PanelTile>
</PanelTileGrid>
</Box>
</Box>
</>
);
}
}
export default withNavigate(OrderList);