This commit is contained in:
seb
2026-10-11 13:44:34 +02:00
parent e8b2ead83f
commit 1e2f57e941
37 changed files with 386 additions and 584 deletions

View File

@@ -49,6 +49,7 @@ import authAutomat from './authAutomat';
const initialState = { const initialState = {
items: [], items: [],
filter: '',
total: 0, total: 0,
offset: 0, offset: 0,
limit: 20, limit: 20,
@@ -58,61 +59,83 @@ const initialState = {
let automat; let automat;
let inflightLoad = null; let inflightLoad = null;
const FETCH_PAGE_SIZE = 100;
// MARK: pages through GET /api/order until every order is collected
async function fetchAllOrders() {
let offset = 0;
let total = Infinity;
const collected = [];
while (offset === 0 || collected.length < total) {
const res = await orderApi.list({ offset, limit: FETCH_PAGE_SIZE });
if (offset === 0) total = Number(res?.total ?? res?.orders?.length ?? 0);
const page = Array.isArray(res?.orders) ? res.orders : [];
collected.push(...page);
offset += page.length;
if (page.length === 0) break;
}
return collected;
}
const applyAll = (orders) => ({
items: orders,
total: orders.length,
offset: 0,
limit: Math.max(orders.length, 1),
status: 'ready',
error: null,
});
/**
* Client-side filter over number, external reference and (resolved) customer name.
* @param {Order[]} items
* @param {string} filter
* @param {(order: Order) => string} [nameOf] Customer name resolver.
*/
export function filterOrders(items, filter, nameOf) {
const query = String(filter ?? '').trim().toLowerCase();
if (!query) return items;
return items.filter((o) =>
[o.number, o.externalNumber, nameOf?.(o)]
.some((v) => String(v ?? '').toLowerCase().includes(query)));
}
const actions = { const actions = {
/** Sets the active search filter. */
filter(query) {
automat.setState({ filter: String(query ?? '') });
},
/** /**
* Loads paginated orders, deduplicating concurrent callers. * Loads all orders, deduplicating concurrent callers.
* *
* @param {{ offset?: number, limit?: number }} [params]
* @returns {Promise<Order[]>} * @returns {Promise<Order[]>}
*/ */
load({ offset, limit } = {}) { load() {
const currentOffset = offset ?? automat.state.offset ?? 0;
const currentLimit = limit ?? automat.state.limit ?? 20;
if (inflightLoad) return inflightLoad; if (inflightLoad) return inflightLoad;
inflightLoad = (async () => { automat.setState({ status: 'loading', error: null });
automat.setState({ status: 'loading', error: null }); inflightLoad = fetchAllOrders()
try { .then((orders) => {
const res = await orderApi.list({ offset: currentOffset, limit: currentLimit }); automat.setState(applyAll(orders));
const orders = res?.orders ?? [];
automat.setState({
items: orders,
total: Number(res?.total ?? orders.length),
offset: Number(res?.offset ?? currentOffset),
limit: Number(res?.limit ?? currentLimit),
status: 'ready',
error: null,
});
return orders; return orders;
} catch (error) { })
.catch((error) => {
automat.setState({ status: 'failed', error }); automat.setState({ status: 'failed', error });
throw error; throw error;
} finally { })
inflightLoad = null; .finally(() => { inflightLoad = null; });
}
})();
return inflightLoad; return inflightLoad;
}, },
/** /**
* Silently re-fetches the current page of orders without flashing loading state. * Silently re-fetches all orders without flashing loading state.
* *
* @returns {Promise<Order[]>} * @returns {Promise<Order[]>}
*/ */
async reload() { async reload() {
const { offset, limit } = automat.state;
try { try {
const res = await orderApi.list({ offset, limit }); const orders = await fetchAllOrders();
const orders = res?.orders ?? []; automat.setState(applyAll(orders));
automat.setState({
items: orders,
total: Number(res?.total ?? orders.length),
offset: Number(res?.offset ?? offset),
limit: Number(res?.limit ?? limit),
status: 'ready',
error: null,
});
return orders; return orders;
} catch (error) { } catch (error) {
automat.setState({ error }); automat.setState({ error });
@@ -120,37 +143,6 @@ const actions = {
} }
}, },
/**
* Navigates to a 1-based page using formula: offset = (page - 1) * limit
*
* @param {number} page 1-based page number.
* @returns {Promise<Order[]>}
*/
setPage(page) {
const limit = automat.state.limit || 20;
const offset = Math.max(0, (page - 1) * limit);
return actions.load({ offset, limit });
},
/**
* Sets pagination offset directly.
*
* @param {number} offset 0-based offset.
* @returns {Promise<Order[]>}
*/
setOffset(offset) {
return actions.load({ offset, limit: automat.state.limit });
},
/**
* Sets page limit (records to return) and resets offset to 0.
*
* @param {number} limit Records per page (1..100).
* @returns {Promise<Order[]>}
*/
setLimit(limit) {
return actions.load({ offset: 0, limit });
},
/** /**
* Ensures orders are loaded once the selected company is known. * Ensures orders are loaded once the selected company is known.

View File

@@ -1,33 +1,23 @@
import { Fragment, PureComponent } from 'react'; import { PureComponent, createRef } from 'react';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';
import Chip from '@mui/material/Chip'; import Chip from '@mui/material/Chip';
import CircularProgress from '@mui/material/CircularProgress'; import CircularProgress from '@mui/material/CircularProgress';
import Collapse from '@mui/material/Collapse';
import IconButton from '@mui/material/IconButton'; import IconButton from '@mui/material/IconButton';
import Stack from '@mui/material/Stack'; import ListItemButton from '@mui/material/ListItemButton';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableContainer from '@mui/material/TableContainer';
import TableFooter from '@mui/material/TableFooter';
import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined'; import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
import { enqueueSnackbar } from 'notistack'; import { enqueueSnackbar } from 'notistack';
import BackendError from '../BackendError'; import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
import FillSpace from 'dashgui/FillSpace';
import { PanelTile, PanelTileGrid } from 'dashgui/panels'; import { PanelTile, PanelTileGrid } from 'dashgui/panels';
import orderAutomat from '../../automats/orderAutomat'; import BackendError from '../BackendError';
import orderAutomat, { filterOrders } from '../../automats/orderAutomat';
import { order as orderApi } from '../../api'; import { order as orderApi } from '../../api';
import { backendErrorFrom } from '../../utils/backendError'; import { backendErrorFrom } from '../../utils/backendError';
import { notifyError } from '../../utils/notify'; import { notifyError } from '../../utils/notify';
import { formatPrice } from '../../utils/price'; import { formatPrice } from '../../utils/price';
import withNavigate from '../withNavigate'; import withNavigate from '../withNavigate';
import ResultsPagination from '../article/ResultsPagination';
import { import {
formatDate, formatDate,
loadParties, loadParties,
@@ -36,532 +26,295 @@ import {
partyDisplayName, partyDisplayName,
} from './manifestConfig'; } from './manifestConfig';
function tintDivider(color, amount = 0.15) { // MARK: pure helpers
if (typeof color !== 'string') return 'divider'; function orderGrossTotal(lines) {
const m = color.trim().match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i); if (!Array.isArray(lines)) return 0;
if (!m) return 'divider';
let hex = m[1];
if (hex.length === 3) {
hex = hex
.split('')
.map((c) => c + c)
.join('');
}
const num = parseInt(hex, 16);
const channels = [16, 8, 0].map((shift) => {
const c = (num >> shift) & 0xff;
return Math.max(0, Math.round(c * (1 - amount)));
});
const toHex = (v) => v.toString(16).padStart(2, '0');
return `#${channels.map(toHex).join('')}`;
}
function calculateOrderTotal(lines) {
if (!Array.isArray(lines) || lines.length === 0) return 0;
return lines.reduce((acc, line) => { return lines.reduce((acc, line) => {
const amount = Number(line.amount) || 0; const net = (Number(line.amount) || 0) * (Number(line.net) || 0) * (1 - (Number(line.rebate) || 0) / 100);
const net = Number(line.net) || 0; return acc + net * (1 + (Number(line.taxrate) || 0) / 100);
const rebate = Number(line.rebate) || 0;
const lineTotal = amount * net * (1 - rebate / 100);
return acc + lineTotal;
}, 0); }, 0);
} }
function calculateOrderGrossTotal(lines) { const cfg = manifestConfig('order');
if (!Array.isArray(lines) || lines.length === 0) return 0;
return lines.reduce((acc, line) => {
const amount = Number(line.amount) || 0;
const net = Number(line.net) || 0;
const rebate = Number(line.rebate) || 0;
const taxrate = Number(line.taxrate) || 0;
const lineNet = amount * net * (1 - rebate / 100);
const lineGross = lineNet * (1 + taxrate / 100);
return acc + lineGross;
}, 0);
}
export class OrderList extends PureComponent { const rowSx = {
constructor(props) { alignItems: 'center',
super(props); height: 64,
this.cfg = manifestConfig('order'); minHeight: 64,
const orderState = orderAutomat.state; maxHeight: 64,
this.state = { boxSizing: 'border-box',
status: orderState.status === 'ready' ? 'ready' : 'loading', py: 0,
error: orderState.error, gap: 1.5,
orders: orderState.items ?? [], };
total: orderState.total ?? 0,
offset: orderState.offset ?? 0,
limit: orderState.limit ?? 20,
partiesById: {},
busyId: null,
expandedIds: new Set(),
};
this._cancelled = false;
this.handleRetry = this.handleRetry.bind(this);
this.handleCreate = this.handleCreate.bind(this);
this.handleEdit = this.handleEdit.bind(this);
this.handleDelete = this.handleDelete.bind(this);
this.handlePageChange = this.handlePageChange.bind(this);
this.toggleExpand = this.toggleExpand.bind(this);
}
componentDidMount() { const ellipsisSx = { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' };
this._cancelled = false;
this.unsubOrder = orderAutomat.subscribe(this, (state) => ({
orders: state.items ?? [],
total: state.total ?? 0,
offset: state.offset ?? 0,
limit: state.limit ?? 20,
status: state.status,
error: state.error,
}));
void this.loadPartiesAndOrders(); /**
} * Ambient context for `OrderRow`: FillSpace renders rows as bare elements
* (`<Row item index />`), so the list publishes callbacks and the party map
componentWillUnmount() { * here on every render.
this._cancelled = true; */
this.unsubOrder?.(); const rowContext = { partiesById: {}, busyId: null, onOpen: null, onDelete: null };
}
async loadPartiesAndOrders() {
try {
const parties = await loadParties('order');
if (this._cancelled) return;
this.setState({
partiesById: this.cfg.partiesByIdFromList(parties),
});
} catch {
// Non-fatal, party names can fall back to IDs
}
try {
await orderAutomat.actions.ensureLoaded();
} catch (err) {
if (this._cancelled) return;
this.setState({ status: 'failed', error: backendErrorFrom(err) });
}
}
async handleRetry() {
this.setState({ status: 'loading', error: null });
try {
await orderAutomat.actions.reload();
} catch (err) {
if (this._cancelled) return;
this.setState({ status: 'failed', error: backendErrorFrom(err) });
}
}
go(to) {
const { navigate } = this.props;
if (typeof navigate === 'function') {
navigate(to);
return;
}
window.location.assign(to);
}
handleCreate() {
this.go(this.cfg.createPath);
}
handleEdit(order) {
this.go(this.cfg.editPath(order.id));
}
async handleDelete(order) {
if (this.state.busyId != null) return;
if (!window.confirm(this.cfg.confirmDelete(order.number))) return;
this.setState({ busyId: order.id });
try {
await orderApi.delete(order.id);
await orderAutomat.actions.reload();
if (this._cancelled) return;
this.setState({ busyId: null });
enqueueSnackbar(this.cfg.deletedMsg, { variant: 'success' });
} catch (err) {
if (this._cancelled) return;
notifyError(err);
this.setState({ busyId: null });
}
}
handlePageChange(page) {
// Formula: Next Offset: (page - 1) * limit
void orderAutomat.actions.setPage(page);
}
toggleExpand(orderId) {
this.setState((prev) => {
const next = new Set(prev.expandedIds);
if (next.has(orderId)) {
next.delete(orderId);
} else {
next.add(orderId);
}
return { expandedIds: next };
});
}
// MARK: one row rendered by FillSpace
class OrderRow extends PureComponent {
partyName(partyId) { partyName(partyId) {
if (partyId == null) return '–'; if (partyId == null) return '–';
const party = this.state.partiesById[partyId]; const party = rowContext.partiesById[partyId];
return partyDisplayName('order', party) || `Kunde #${partyId}`; return partyDisplayName('order', party) || `Kunde #${partyId}`;
} }
renderLoading() { render() {
return ( const { item: order } = this.props;
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
<CircularProgress />
</Box>
);
}
renderLineItems(order, borderColor) { // Data-free probe (FillSpace measures the row height with `item` undefined).
const lines = order.lines ?? []; if (!order) {
if (lines.length === 0) {
return ( return (
<Box sx={{ py: 1.5, px: 2, bgcolor: 'action.hover', borderRadius: 1 }}> <ListItemButton disabled divider sx={{ ...rowSx, opacity: 0, pointerEvents: 'none' }}>
<Typography variant="body2" color="text.secondary"> <Typography variant="body2">&nbsp;</Typography>
Keine Positionen vorhanden. </ListItemButton>
</Typography>
</Box>
); );
} }
const netTotal = calculateOrderTotal(lines); const { busyId, onOpen, onDelete } = rowContext;
const grossTotal = calculateOrderGrossTotal(lines); const lines = order.lines ?? [];
const gross = orderGrossTotal(lines);
return ( return (
<Box <ListItemButton divider sx={rowSx} onClick={() => onOpen?.(order)}>
sx={{ <Box sx={{ width: 120, flexShrink: 0, minWidth: 0 }}>
py: 1, <Typography variant="body1" sx={{ fontWeight: 600, ...ellipsisSx }}>
px: 1.5, {order.number}
bgcolor: 'rgba(255, 255, 255, 0.45)', </Typography>
borderRadius: 1, <Typography variant="body2" color="text.secondary" sx={ellipsisSx}>
border: '1px solid', {numberDisplay(order.externalNumber)}
borderColor, </Typography>
}} </Box>
>
<Table size="small" aria-label={`Positionen für Bestellung ${order.number}`}>
<TableHead>
<TableRow>
<TableCell align="center" sx={{ fontWeight: 600, fontSize: '0.75rem', width: 44 }}>
Pos
</TableCell>
<TableCell sx={{ fontWeight: 600, fontSize: '0.75rem' }}>SKU</TableCell>
<TableCell sx={{ fontWeight: 600, fontSize: '0.75rem' }}>Artikel</TableCell>
<TableCell align="right" sx={{ fontWeight: 600, fontSize: '0.75rem' }}>
Menge
</TableCell>
<TableCell align="right" sx={{ fontWeight: 600, fontSize: '0.75rem' }}>
Netto
</TableCell>
<TableCell align="right" sx={{ fontWeight: 600, fontSize: '0.75rem' }}>
Brutto
</TableCell>
<TableCell align="right" sx={{ fontWeight: 600, fontSize: '0.75rem' }}>
Rabatt
</TableCell>
<TableCell align="right" sx={{ fontWeight: 600, fontSize: '0.75rem' }}>
MwSt.
</TableCell>
<TableCell align="right" sx={{ fontWeight: 600, fontSize: '0.75rem' }}>
Gesamt netto
</TableCell>
<TableCell align="right" sx={{ fontWeight: 600, fontSize: '0.75rem' }}>
Gesamt brutto
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{lines.map((line, idx) => {
const amount = Number(line.amount) || 0;
const net = Number(line.net) || 0;
const taxrate = Number(line.taxrate) || 0;
const gross = net * (1 + taxrate / 100);
const rebate = Number(line.rebate) || 0;
const lineNet = amount * net * (1 - rebate / 100);
const lineGross = lineNet * (1 + taxrate / 100);
const puText = Number(line.pu) > 1 ? ` (VPE ${line.pu})` : '';
return ( <Box sx={{ flex: 1, minWidth: 0 }}>
<TableRow key={line.id ?? `line-${idx}`}> <Typography variant="body1" sx={{ fontWeight: 500, ...ellipsisSx }}>
<TableCell align="center" sx={{ fontSize: '0.75rem', color: 'text.secondary', fontWeight: 600 }}> {this.partyName(order.entityId)}
{idx + 1} </Typography>
</TableCell> <Typography variant="body2" color="text.secondary" sx={ellipsisSx}>
<TableCell sx={{ fontFamily: 'monospace', fontSize: '0.75rem' }}> {cfg.dateLabel} {formatDate(order.date)}
{line.sku || '–'} </Typography>
</TableCell> </Box>
<TableCell sx={{ fontSize: '0.75rem' }}>
<Typography variant="body2" sx={{ fontSize: '0.75rem', fontWeight: 500 }}> <Chip
{line.name || '–'} size="small"
</Typography> label={`${lines.length} Pos.`}
{line.comment ? ( sx={{
<Typography variant="caption" color="text.secondary"> width: 64,
{line.comment} flexShrink: 0,
</Typography> height: 24,
) : null} fontSize: '0.75rem',
</TableCell> fontWeight: 600,
<TableCell align="right" sx={{ fontSize: '0.75rem' }}> fontVariantNumeric: 'tabular-nums',
{amount} bgcolor: 'action.hover',
{puText} color: lines.length > 0 ? 'text.primary' : 'text.disabled',
</TableCell> }}
<TableCell align="right" sx={{ fontSize: '0.75rem' }}> />
{formatPrice(net)}
</TableCell> <Typography
<TableCell align="right" sx={{ fontSize: '0.75rem' }}> variant="body2"
{formatPrice(gross)} sx={{ width: 96, flexShrink: 0, textAlign: 'right', fontWeight: 600, fontVariantNumeric: 'tabular-nums' }}
</TableCell> >
<TableCell align="right" sx={{ fontSize: '0.75rem' }}> {gross > 0 ? formatPrice(gross) : '—'}
{rebate > 0 ? `${rebate}%` : '–'} </Typography>
</TableCell>
<TableCell align="right" sx={{ fontSize: '0.75rem' }}> <IconButton
{line.taxrate != null ? `${line.taxrate}%` : '–'} aria-label={`„${order.number}“ löschen`}
</TableCell> disabled={busyId != null}
<TableCell align="right" sx={{ fontSize: '0.75rem', fontWeight: 600 }}> onClick={(e) => {
{formatPrice(lineNet)} e.stopPropagation();
</TableCell> onDelete?.(order);
<TableCell align="right" sx={{ fontSize: '0.75rem', fontWeight: 600, color: 'primary.dark' }}> }}
{formatPrice(lineGross)} onMouseDown={(e) => e.stopPropagation()}
</TableCell> sx={{ width: 40, height: 40, flexShrink: 0 }}
</TableRow> >
); <DeleteOutlinedIcon sx={{ fontSize: 24 }} />
})} </IconButton>
</TableBody> </ListItemButton>
<TableFooter>
<TableRow sx={{ '& > td': { borderTop: '2px solid', borderColor, fontWeight: 700 } }}>
<TableCell colSpan={8} align="right" sx={{ fontSize: '0.75rem', fontWeight: 700 }}>
Gesamtsumme:
</TableCell>
<TableCell align="right" sx={{ fontSize: '0.75rem', fontWeight: 700 }}>
{formatPrice(netTotal)}
</TableCell>
<TableCell align="right" sx={{ fontSize: '0.75rem', fontWeight: 700, color: 'primary.dark' }}>
{formatPrice(grossTotal)}
</TableCell>
</TableRow>
</TableFooter>
</Table>
</Box>
); );
} }
}
// MARK: search + create bar
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() { render() {
const { status, error, orders, total, offset, limit, busyId, expandedIds } = this.state; const { inputRef, onCreate } = this.props;
const cfg = this.cfg;
if (status === 'loading') return this.renderLoading();
if (status === 'failed') {
return (
<BackendError
error={error}
onRetry={this.handleRetry}
/>
);
}
const borderColor = tintDivider(cfg.listColor);
// Client Pagination Formula:
// Current Page: Math.floor(offset / limit) + 1
// Total Pages: Math.ceil(total / limit)
const currentPage = Math.floor(offset / limit) + 1;
const pageCount = Math.max(1, Math.ceil(total / limit));
const showPagination = total > limit;
return ( return (
<PanelTileGrid> <PanelTileGrid>
<PanelTile wide={3} minHeight={240} color={cfg.listColor}> <Box
<Stack spacing={2}> ref={inputRef}
{/* Create Header Button */} sx={{
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}> gridColumn: { xs: '1 / -1', sm: 'span 2', md: 'span 2' },
<Button display: 'flex',
type="button" alignItems: 'center',
variant="contained" gap: 1,
size="small" pb: 2,
onClick={this.handleCreate} }}
> >
{cfg.createButton} <SingleLineTextEdit
</Button> sx={{ flex: 1 }}
</Box> size="small"
search
{/* Empty State */} clear
{orders.length === 0 ? ( value={this.state.input}
<Typography variant="body2" color="text.secondary"> onChange={(next) => orderAutomat.actions.filter(next)}
{cfg.emptyList} />
</Typography> <Button variant="contained" onClick={onCreate}>
) : ( {cfg.createButton}
<TableContainer> </Button>
<Table </Box>
size="small"
sx={{
'& .MuiTableCell-root': {
borderColor,
},
}}
>
<TableHead>
<TableRow>
<TableCell padding="checkbox" sx={{ width: 44 }} />
<TableCell sx={{ width: 130, fontWeight: 600 }}>Nummer</TableCell>
<TableCell sx={{ width: 130, fontWeight: 600 }}>Referenz</TableCell>
<TableCell sx={{ width: 130, fontWeight: 600 }}>{cfg.dateLabel}</TableCell>
<TableCell sx={{ minWidth: 160, fontWeight: 600 }}>{cfg.partyLabel}</TableCell>
<TableCell sx={{ minWidth: 180, fontWeight: 600 }}>Positionen</TableCell>
<TableCell padding="checkbox" align="right" sx={{ width: 84 }} />
</TableRow>
</TableHead>
<TableBody>
{orders.map((order) => {
const isExpanded = expandedIds.has(order.id);
const lines = order.lines ?? [];
const netTotal = calculateOrderTotal(lines);
const grossTotal = calculateOrderGrossTotal(lines);
const hasLines = lines.length > 0;
return (
<Fragment key={order.id}>
<TableRow
hover
sx={{ cursor: 'pointer' }}
onClick={() => this.handleEdit(order)}
>
{/* Expand / Collapse toggle */}
<TableCell
padding="checkbox"
onClick={(e) => {
e.stopPropagation();
if (hasLines) this.toggleExpand(order.id);
}}
>
{hasLines ? (
<IconButton size="small" aria-label="Positionen ein-/ausblenden">
{isExpanded ? (
<KeyboardArrowUpIcon fontSize="small" />
) : (
<KeyboardArrowDownIcon fontSize="small" />
)}
</IconButton>
) : null}
</TableCell>
{/* Number */}
<TableCell sx={{ fontWeight: 600 }}>{order.number}</TableCell>
{/* Reference / External Number */}
<TableCell>{numberDisplay(order.externalNumber)}</TableCell>
{/* Date */}
<TableCell>{formatDate(order.date)}</TableCell>
{/* Customer */}
<TableCell>{this.partyName(order.entityId)}</TableCell>
{/* Lines summary */}
<TableCell>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Chip
size="small"
label={`${lines.length} Pos.`}
variant="outlined"
sx={{
bgcolor: 'background.paper',
fontWeight: 500,
fontSize: '0.75rem',
height: 22,
}}
/>
{grossTotal > 0 ? (
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<Typography variant="body2" sx={{ fontWeight: 700, fontSize: '0.8rem', lineHeight: 1.2 }}>
{formatPrice(grossTotal)} brutto
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: '0.7rem' }}>
{formatPrice(netTotal)} netto
</Typography>
</Box>
) : netTotal > 0 ? (
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '0.8rem' }}>
{formatPrice(netTotal)}
</Typography>
) : null}
</Box>
</TableCell>
{/* Row Action buttons */}
<TableCell
padding="checkbox"
align="right"
onClick={(e) => e.stopPropagation()}
>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 0.5 }}>
<IconButton
size="small"
aria-label={`„${order.number}“ bearbeiten`}
onClick={() => this.handleEdit(order)}
>
<EditOutlinedIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
aria-label={`„${order.number}“ löschen`}
disabled={busyId != null}
onClick={() => this.handleDelete(order)}
>
<DeleteOutlinedIcon fontSize="small" />
</IconButton>
</Box>
</TableCell>
</TableRow>
{/* Collapsible Line Details Row */}
{hasLines && (
<TableRow>
<TableCell
colSpan={7}
sx={{
p: 0,
borderBottom: isExpanded ? undefined : 'none',
}}
>
<Collapse in={isExpanded} timeout="auto" unmountOnExit>
<Box sx={{ py: 1.5, px: 2 }}>
{this.renderLineItems(order, borderColor)}
</Box>
</Collapse>
</TableCell>
</TableRow>
)}
</Fragment>
);
})}
</TableBody>
</Table>
</TableContainer>
)}
{/* Pagination Controls */}
{showPagination ? (
<Box sx={{ pt: 1 }}>
<ResultsPagination
offset={offset}
totalCount={total}
pageSize={limit}
pageCount={pageCount}
currentPage={currentPage}
onPageChange={(e, page) => this.handlePageChange(page)}
/>
</Box>
) : null}
</Stack>
</PanelTile>
</PanelTileGrid> </PanelTileGrid>
); );
} }
} }
// MARK: list page
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,
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.nameOf = this.nameOf.bind(this);
}
componentDidMount() {
this._cancelled = false;
this.unsub = orderAutomat.subscribe(this, (state) => ({
items: state.items ?? [],
filter: state.filter ?? '',
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) this.setState({ partiesById: cfg.partiesByIdFromList(parties) });
} catch {
// Non-fatal: names fall back to ids.
}
try {
await orderAutomat.actions.ensureLoaded();
} catch {
// Surfaced through automat state.
}
}
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, filter, 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;
const rows = filterOrders(items, filter, this.nameOf);
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}
/>
)}
</PanelTile>
</PanelTileGrid>
</>
);
}
}
export default withNavigate(OrderList); export default withNavigate(OrderList);

View File

@@ -645,7 +645,6 @@ class ArtikelSearchPage extends Component {
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
onClick={() => { onClick={() => {
const next = selected ? [] : [id]; const next = selected ? [] : [id];
this.setState({ categoryFilterOpen: false });
this.updateParams(input, buildFilter(next), 0); this.updateParams(input, buildFilter(next), 0);
}} }}
sx={{ pl: 2 + depth * 1.5 }} sx={{ pl: 2 + depth * 1.5 }}

View File

@@ -110,6 +110,7 @@ class ArtikelMarkenPage extends PureComponent {
status: brandAutomat.state.status, status: brandAutomat.state.status,
error: brandAutomat.state.error ? backendErrorFrom({ message: brandAutomat.state.error }) : null, error: brandAutomat.state.error ? backendErrorFrom({ message: brandAutomat.state.error }) : null,
brands: brandAutomat.state.filteredBrands ?? brandAutomat.state.brands, brands: brandAutomat.state.filteredBrands ?? brandAutomat.state.brands,
totalCount: brandAutomat.state.brands?.length ?? 0,
}; };
this.searchRef = createRef(); this.searchRef = createRef();
this.handleRetry = this.handleRetry.bind(this); this.handleRetry = this.handleRetry.bind(this);
@@ -120,6 +121,7 @@ class ArtikelMarkenPage extends PureComponent {
this.unsub = brandAutomat.subscribe(this, (state) => ({ this.unsub = brandAutomat.subscribe(this, (state) => ({
brands: state.filteredBrands, brands: state.filteredBrands,
totalCount: state.brands?.length ?? 0,
status: state.status, status: state.status,
error: state.error ? backendErrorFrom({ message: state.error }) : null, error: state.error ? backendErrorFrom({ message: state.error }) : null,
})); }));
@@ -159,6 +161,7 @@ class ArtikelMarkenPage extends PureComponent {
minPageSize={8} minPageSize={8}
minHeight={0} minHeight={0}
items={brands} items={brands}
totalCount={this.state.totalCount}
renderItem={ArtikelMarke} renderItem={ArtikelMarke}
/> />
</PanelTile> </PanelTile>

View File

@@ -499,6 +499,7 @@ class ArtikelMerkmalePage extends PureComponent {
) : ( ) : (
<FillSpace <FillSpace
items={filteredAttributes} items={filteredAttributes}
totalCount={attributes.length}
minPageSize={8} minPageSize={8}
maxPageSize={8} maxPageSize={8}
listRef={this.attrListRef} listRef={this.attrListRef}
@@ -567,6 +568,7 @@ class ArtikelMerkmalePage extends PureComponent {
) : ( ) : (
<FillSpace <FillSpace
items={filteredValues} items={filteredValues}
totalCount={selectedValues.length}
minPageSize={8} minPageSize={8}
maxPageSize={8} maxPageSize={8}
listRef={this.valListRef} listRef={this.valListRef}
@@ -629,6 +631,7 @@ class ArtikelMerkmalePage extends PureComponent {
) : ( ) : (
<FillSpace <FillSpace
items={filteredProperties} items={filteredProperties}
totalCount={properties.length}
minPageSize={8} minPageSize={8}
maxPageSize={8} maxPageSize={8}
listRef={this.propListRef} listRef={this.propListRef}

View File

@@ -1,5 +1,5 @@
import { PureComponent } from 'react'; import { PureComponent } from 'react';
import { OrderList } from '../../components/manifest'; import OrderList from '../../components/manifest/OrderList';
class AuftragBestellungPage extends PureComponent { class AuftragBestellungPage extends PureComponent {
render() { render() {

View File

@@ -190,7 +190,7 @@ async function main() {
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
const build = spawn('npx', ['vite', 'build'], { const build = spawn('npx', ['vite', 'build'], {
cwd: FRONT_ROOT, cwd: FRONT_ROOT,
env: { ...process.env }, env: { ...process.env, NODE_ENV: 'production' },
stdio: ['ignore', 'pipe', 'pipe'], stdio: ['ignore', 'pipe', 'pipe'],
}); });
build.stdout.on('data', () => process.stdout.write('[build] ')); build.stdout.on('data', () => process.stdout.write('[build] '));
@@ -208,7 +208,7 @@ async function main() {
const server = startProc( const server = startProc(
'npx', 'npx',
['vite', 'preview', '--port', String(VITE_PORT), '--strictPort'], ['vite', 'preview', '--port', String(VITE_PORT), '--strictPort'],
{ RIG_API_TARGET: `http://localhost:${DUMMY_PORT}` }, { RIG_API_TARGET: `http://localhost:${DUMMY_PORT}`, NODE_ENV: 'production' },
'preview', 'preview',
); );
await waitForPort(VITE_PORT); await waitForPort(VITE_PORT);
@@ -248,6 +248,7 @@ async function main() {
height: viewport.height, height: viewport.height,
settleMs: SETTLE_MS, settleMs: SETTLE_MS,
dialog: route.dialog, dialog: route.dialog,
icons: route.icons,
}); });
const kb = (buf.length / 1024).toFixed(0); const kb = (buf.length / 1024).toFixed(0);

View File

@@ -161,7 +161,7 @@ async function httpJson(url, options = {}) {
* Opens a new page target on the browser, navigates, waits for the app root * Opens a new page target on the browser, navigates, waits for the app root
* to contain content + network to quiet down, captures a PNG. * to contain content + network to quiet down, captures a PNG.
*/ */
export async function capturePage(debugPort, url, file, { width, height, settleMs = 6000, dialog }) { export async function capturePage(debugPort, url, file, { width, height, settleMs = 6000, dialog, icons }) {
// Chrome ≥136 requires PUT for /json/new. // Chrome ≥136 requires PUT for /json/new.
const target = await httpJson(`http://127.0.0.1:${debugPort}/json/new?${encodeURIComponent(url)}`, { const target = await httpJson(`http://127.0.0.1:${debugPort}/json/new?${encodeURIComponent(url)}`, {
method: 'PUT', method: 'PUT',
@@ -257,6 +257,55 @@ export async function capturePage(debugPort, url, file, { width, height, settleM
} }
} }
// Icon sheet: replace the page by a white sheet with every distinct icon
// (inline SVG) found on it, drawn in black.
if (icons) {
await session.send('Runtime.evaluate', {
expression: `(() => {
const seen = new Map();
for (const svg of document.querySelectorAll('svg')) {
const r = svg.getBoundingClientRect();
if (r.width > 64 || r.height > 64) continue; // skip illustrations / spinners
if (svg.closest('[role="progressbar"]')) continue;
const key = svg.getAttribute('data-testid') || svg.innerHTML;
if (!svg.innerHTML || seen.has(key)) continue;
seen.set(key, svg);
}
const style = document.createElement('style');
style.textContent = \`
#icon-sheet{position:absolute;inset:0;background:#fff;color:#000;padding:24px;
display:flex;flex-wrap:wrap;gap:20px;align-content:flex-start;font:11px sans-serif}
#icon-sheet .cell{width:144px;display:flex;flex-direction:column;align-items:center;gap:6px;text-align:center;word-break:break-all}
#icon-sheet svg{width:144px;height:144px;color:#000;fill:#000}
#icon-sheet svg *{fill:#000;color:#000}
#icon-sheet svg [stroke]:not([stroke=none]){stroke:#000}
\`;
document.head.appendChild(style);
document.getElementById('root').style.display = 'none';
const sheet = document.createElement('div');
sheet.id = 'icon-sheet';
const sorted = [...seen.entries()].sort((a, b) => String(a[0]).localeCompare(String(b[0])));
for (const [key, svg] of sorted) {
const cell = document.createElement('div');
cell.className = 'cell';
const clone = svg.cloneNode(true);
clone.removeAttribute('class');
clone.removeAttribute('style');
clone.removeAttribute('width');
clone.removeAttribute('height');
cell.appendChild(clone);
const label = document.createElement('span');
label.textContent = svg.getAttribute('data-testid') || '';
cell.appendChild(label);
sheet.appendChild(cell);
}
document.body.style.background = '#fff';
document.body.appendChild(sheet);
})()`,
});
await wait(300);
}
// Determinism: hide carets and scrollbars. // Determinism: hide carets and scrollbars.
await session.send('Runtime.evaluate', { await session.send('Runtime.evaluate', {
expression: `(() => { const s = document.createElement('style'); expression: `(() => { const s = document.createElement('style');

View File

@@ -20,6 +20,8 @@ export const ROUTES = [
{ name: 'vorlagen', path: '/artikel/vorlagen' }, { name: 'vorlagen', path: '/artikel/vorlagen' },
{ name: 'merkmale', path: '/artikel/merkmale' }, { name: 'merkmale', path: '/artikel/merkmale' },
{ name: 'artikel-detail', path: '/artikel/ART-000001' }, { name: 'artikel-detail', path: '/artikel/ART-000001' },
// Same page, but the shot is a white sheet with every icon on it in black.
{ name: 'artikel-detail-icons', path: '/artikel/ART-000001', icons: true },
{ name: 'lieferung', path: '/lieferung' }, { name: 'lieferung', path: '/lieferung' },
{ name: 'lieferung-neu', path: '/lieferung/neu' }, { name: 'lieferung-neu', path: '/lieferung/neu' },
{ name: 'lieferanten', path: '/lieferung/lieferanten' }, { name: 'lieferanten', path: '/lieferung/lieferanten' },

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 194 KiB

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 223 KiB

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 164 KiB

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 173 KiB

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 194 KiB

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 KiB

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 172 KiB

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

After

Width:  |  Height:  |  Size: 155 KiB