Files
dashfront/src/components/manifest/OrderList.jsx
2026-10-13 01:50:36 +02:00

622 lines
20 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 DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
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 { order as orderApi } from '../../api';
import { backendErrorFrom } from '../../utils/backendError';
import { notifyError } from '../../utils/notify';
import { formatPrice } from '../../utils/price';
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');
const rowSx = {
alignItems: 'center',
height: 64,
minHeight: 64,
maxHeight: 64,
boxSizing: 'border-box',
py: 0,
gap: 1.5,
};
const ellipsisSx = { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' };
/**
* 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,
};
// 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;
// Data-free probe (FillSpace measures the row height with `item` undefined).
if (!order) {
if (disabled) {
return (
<ListItemButton disabled divider sx={{ ...rowSx, opacity: 0, pointerEvents: 'none' }}>
<Typography variant="body2">&nbsp;</Typography>
</ListItemButton>
);
}
return (
<ListItemButton disabled divider sx={rowSx}>
<Box sx={{ width: 120, flexShrink: 0 }}>
<Skeleton variant="text" width={80} height={20} />
<Skeleton variant="text" width={50} height={16} />
</Box>
<Box sx={{ width: 100, flexShrink: 0 }}>
<Skeleton variant="text" width={70} height={20} />
</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Skeleton variant="text" width="60%" height={20} />
</Box>
<Box sx={{ width: 72, flexShrink: 0, display: 'flex', justifyContent: 'center' }}>
<Skeleton variant="circular" width={20} height={20} />
</Box>
<Box sx={{ width: 72, flexShrink: 0, display: 'flex', justifyContent: 'center' }}>
<Skeleton variant="circular" width={20} height={20} />
</Box>
<Box sx={{ width: 40, flexShrink: 0, display: 'flex', justifyContent: 'center' }}>
<Skeleton variant="rounded" width={28} height={20} />
</Box>
<Box sx={{ width: 96, flexShrink: 0, display: 'flex', justifyContent: 'flex-end' }}>
<Skeleton variant="text" width={60} height={20} />
</Box>
<Box sx={{ width: 40, flexShrink: 0 }} />
</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={rowSx} onClick={() => onOpen?.(order)}>
<Box sx={{ width: 120, flexShrink: 0, minWidth: 0 }}>
<Typography variant="body1" sx={{ fontWeight: 600, ...ellipsisSx }}>
{order.number}
</Typography>
<Typography variant="body2" color="text.secondary" sx={ellipsisSx}>
{numberDisplay(order.externalNumber)}
</Typography>
</Box>
<Box sx={{ width: 100, flexShrink: 0, minWidth: 0 }}>
<Typography variant="body2" color="text.secondary" sx={ellipsisSx}>
{order.date ? formatDate(order.date) : '–'}
</Typography>
</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="body1" sx={{ fontWeight: 500, ...ellipsisSx }}>
{this.partyName(order.entityId)}
</Typography>
</Box>
<Box sx={{ width: 72, flexShrink: 0, display: 'flex', justifyContent: 'center' }}>
<Checkbox
size="small"
checked={paid}
color="success"
onClick={(e) => e.stopPropagation()}
onChange={(e) => onTogglePaid?.(order, e.target.checked)}
inputProps={{ 'aria-label': `Auftrag ${order.number} bezahlt` }}
sx={{ p: 0.5 }}
/>
</Box>
<Box sx={{ width: 72, flexShrink: 0, display: 'flex', justifyContent: 'center' }}>
<Checkbox
size="small"
checked={delivered}
color="primary"
onClick={(e) => e.stopPropagation()}
onChange={(e) => onToggleDelivered?.(order, e.target.checked)}
inputProps={{ 'aria-label': `Auftrag ${order.number} geliefert` }}
sx={{ p: 0.5 }}
/>
</Box>
<Chip
size="small"
label={lines.length}
title={`${lines.length} Positionen`}
sx={{
width: 40,
flexShrink: 0,
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 },
}}
/>
<Typography
variant="body2"
sx={{ width: 96, flexShrink: 0, textAlign: 'right', fontWeight: 600, fontVariantNumeric: 'tabular-nums' }}
>
{gross > 0 ? formatPrice(gross) : '—'}
</Typography>
<IconButton
aria-label={`„${order.number}“ löschen`}
disabled={busyId != null}
onClick={(e) => {
e.stopPropagation();
onDelete?.(order);
}}
onMouseDown={(e) => e.stopPropagation()}
sx={{ width: 40, height: 40, flexShrink: 0 }}
>
<DeleteOutlinedIcon sx={{ fontSize: 24 }} />
</IconButton>
</ListItemButton>
);
}
}
// MARK: <OrderSearchField/>
class OrderSearchField extends PureComponent {
constructor(props) {
super(props);
const s = orderAutomat.state;
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,
};
}
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,
}));
}
componentWillUnmount() {
this.unsub?.();
}
render() {
const { inputRef, onCreate } = 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 & create button */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
}}
>
<SingleLineTextEdit
sx={{ flex: 1 }}
size="small"
search
clear
value={input}
onChange={(next) => orderAutomat.actions.filter(next)}
/>
<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>
</Box>
</Box>
</Box>
</PanelTileGrid>
);
}
}
// MARK: <OrderList/>
export class OrderList extends PureComponent {
constructor(props) {
super(props);
// getItems() returns current items or null and triggers loading of the first chunk
this.state = {
items: orderAutomat.getItems(),
partiesById: {},
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);
}
componentDidMount() {
this._cancelled = false;
// Subscribe to items only
this.unsub = orderAutomat.subscribe(this, (state) => ({
items: state.items,
}));
void this.loadParties();
}
componentWillUnmount() {
this._cancelled = true;
this.unsub?.();
}
async loadParties() {
try {
const parties = await loadParties('order');
if (!this._cancelled) {
this.setState({ partiesById: cfg.partiesByIdFromList(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, partiesById, busyId } = this.state;
if (items === null) {
if (orderAutomat.state.status === 'failed') {
return <BackendError error={backendErrorFrom(orderAutomat.state.error)} onRetry={this.handleRetry} />;
}
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
<CircularProgress />
</Box>
);
}
rowContext.partiesById = partiesById;
rowContext.busyId = busyId;
rowContext.onOpen = this.handleOpen;
rowContext.onDelete = this.handleDelete;
rowContext.onTogglePaid = this.handleTogglePaid;
rowContext.onToggleDelivered = this.handleToggleDelivered;
return (
<>
<OrderSearchField inputRef={this.searchRef} onCreate={this.handleCreate} />
<PanelTileGrid>
<PanelTile wide={3} minHeight={0} color={cfg.listColor} sx={{ pb: 0 }}>
<FillSpace
aboveRef={[this.searchRef]}
reserveBottom={32}
minPageSize={8}
minHeight={0}
items={items}
totalCount={items.length}
renderItem={OrderRow}
virtualScrollbar
header={
<Box
sx={{
display: 'flex',
alignItems: 'center',
height: 36,
minHeight: 36,
px: 2,
gap: 1.5,
borderBottom: '1px solid',
borderColor: 'divider',
color: 'text.secondary',
typography: 'caption',
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.04em',
userSelect: 'none',
}}
>
<Box sx={{ width: 120, flexShrink: 0 }}>Nr. / Ref.</Box>
<Box sx={{ width: 100, flexShrink: 0 }}>Datum</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>Kunde</Box>
<Box sx={{ width: 72, flexShrink: 0, textAlign: 'center' }}>Bezahlt</Box>
<Box sx={{ width: 72, flexShrink: 0, textAlign: 'center' }}>Geliefert</Box>
<Box sx={{ width: 40, flexShrink: 0, textAlign: 'center' }}>Pos.</Box>
<Box sx={{ width: 96, flexShrink: 0, textAlign: 'right' }}>Gesamt</Box>
<Box sx={{ width: 40, flexShrink: 0 }} />
</Box>
}
onPageChange={this.handlePageChange}
/>
</PanelTile>
</PanelTileGrid>
</>
);
}
}
export default withNavigate(OrderList);