Files
dashfront/src/components/manifest/OrderList.jsx
2026-10-11 17:27:33 +02:00

453 lines
14 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 IconButton from '@mui/material/IconButton';
import ListItemButton from '@mui/material/ListItemButton';
import Typography from '@mui/material/Typography';
import Checkbox from '@mui/material/Checkbox';
import Tooltip from '@mui/material/Tooltip';
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, { filterOrders } 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);
}
function isOrderPaid(order) {
return Boolean(order.paid ?? order.payed ?? order.isPaid ?? order.is_paid ?? order.paidAt);
}
function isOrderDelivered(order) {
return Boolean(order.delivered ?? order.isDelivered ?? order.is_delivered ?? order.deliveredAt);
}
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 } = this.props;
// Data-free probe (FillSpace measures the row height with `item` undefined).
if (!order) {
return (
<ListItemButton disabled divider sx={{ ...rowSx, opacity: 0, pointerEvents: 'none' }}>
<Typography variant="body2">&nbsp;</Typography>
</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' }}>
<Tooltip title={paid ? 'Bezahlt' : 'Offen (nicht bezahlt)'}>
<span>
<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 }}
/>
</span>
</Tooltip>
</Box>
<Box sx={{ width: 72, flexShrink: 0, display: 'flex', justifyContent: 'center' }}>
<Tooltip title={delivered ? 'Geliefert' : 'Ausstehend (nicht geliefert)'}>
<span>
<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 }}
/>
</span>
</Tooltip>
</Box>
<Chip
size="small"
label={`${lines.length} Pos.`}
sx={{
width: 64,
flexShrink: 0,
height: 24,
fontSize: '0.75rem',
fontWeight: 600,
fontVariantNumeric: 'tabular-nums',
bgcolor: 'action.hover',
color: lines.length > 0 ? 'text.primary' : 'text.disabled',
}}
/>
<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);
this.state = { input: orderAutomat.state.filter ?? '' };
}
componentDidMount() {
this.unsub = orderAutomat.subscribe(this, (state) => ({ input: state.filter }));
}
componentWillUnmount() {
this.unsub?.();
}
render() {
const { inputRef, onCreate } = this.props;
return (
<PanelTileGrid>
<Box
ref={inputRef}
sx={{
gridColumn: { xs: '1 / -1', sm: 'span 2', md: 'span 2' },
display: 'flex',
alignItems: 'center',
gap: 1,
pb: 2,
}}
>
<SingleLineTextEdit
sx={{ flex: 1 }}
size="small"
search
clear
value={this.state.input}
onChange={(next) => orderAutomat.actions.filter(next)}
/>
<Button variant="contained" onClick={onCreate}>
{cfg.createButton}
</Button>
</Box>
</PanelTileGrid>
);
}
}
// MARK: <OrderList/>
export class OrderList extends PureComponent {
constructor(props) {
super(props);
const s = orderAutomat.state;
this.state = {
status: s.status === 'ready' ? 'ready' : 'loading',
error: s.error ? backendErrorFrom(s.error) : null,
filteredItems: orderAutomat.actions.getOrLoad(),
items: s.items ?? [],
filter: s.filter ?? '',
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);
this.nameOf = this.nameOf.bind(this);
}
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);
}
}
handlePageChange(page, meta) {
const pageCount = meta?.pageCount ?? 1;
// Load next batch when the pagination comes close to the end (within 2 pages).
if (pageCount - page <= 2) {
void orderAutomat.actions.loadMore();
}
}
componentDidMount() {
this._cancelled = false;
this.unsub = orderAutomat.subscribe(this, (state) => ({
items: state.items ?? [],
filter: state.filter ?? '',
filteredItems: state.filter
? filterOrders(state.items ?? [], state.filter, this.nameOf)
: (state.items ?? []),
status: state.status,
error: state.error ? backendErrorFrom(state.error) : null,
}));
void this.loadAll();
}
componentWillUnmount() {
this._cancelled = true;
this.unsub?.();
}
async loadAll() {
try {
const parties = await loadParties('order');
if (!this._cancelled) {
const partiesById = cfg.partiesByIdFromList(parties);
this.setState((prev) => ({
partiesById,
filteredItems: prev.filter
? filterOrders(prev.items, prev.filter, (o) => partyDisplayName('order', partiesById[o.entityId]))
: prev.filteredItems,
}));
}
} catch {
// Non-fatal: names fall back to ids.
}
}
nameOf(order) {
const party = this.state.partiesById[order.entityId];
return partyDisplayName('order', party);
}
handleRetry() {
void orderAutomat.actions.load().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 });
}
}
render() {
const { status, error, items, filteredItems, partiesById, busyId } = this.state;
if (status === 'loading' || status === 'idle') {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
<CircularProgress />
</Box>
);
}
if (status === 'failed') {
return <BackendError error={error} onRetry={this.handleRetry} />;
}
rowContext.partiesById = partiesById;
rowContext.busyId = busyId;
rowContext.onOpen = this.handleOpen;
rowContext.onDelete = this.handleDelete;
rowContext.onTogglePaid = this.handleTogglePaid;
rowContext.onToggleDelivered = this.handleToggleDelivered;
const rows = filteredItems ?? [];
return (
<>
<OrderSearchField inputRef={this.searchRef} onCreate={this.handleCreate} />
<PanelTileGrid>
<PanelTile wide={2} minHeight={0} color={cfg.listColor} sx={{ pb: 0 }}>
{items.length === 0 ? (
<Typography variant="body2" color="text.secondary" sx={{ pb: 2 }}>
{cfg.emptyList}
</Typography>
) : (
<FillSpace
aboveRef={[this.searchRef]}
reserveBottom={32}
minPageSize={8}
minHeight={0}
items={rows}
totalCount={items.length}
renderItem={OrderRow}
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: 64, 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);