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 = {
items: [],
filter: '',
total: 0,
offset: 0,
limit: 20,
@@ -58,61 +59,83 @@ const initialState = {
let automat;
let inflightLoad = null;
const FETCH_PAGE_SIZE = 100;
const actions = {
/**
* Loads paginated orders, deduplicating concurrent callers.
*
* @param {{ offset?: number, limit?: number }} [params]
* @returns {Promise<Order[]>}
*/
load({ offset, limit } = {}) {
const currentOffset = offset ?? automat.state.offset ?? 0;
const currentLimit = limit ?? automat.state.limit ?? 20;
// 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;
}
if (inflightLoad) return inflightLoad;
inflightLoad = (async () => {
automat.setState({ status: 'loading', error: null });
try {
const res = await orderApi.list({ offset: currentOffset, limit: currentLimit });
const orders = res?.orders ?? [];
automat.setState({
const applyAll = (orders) => ({
items: orders,
total: Number(res?.total ?? orders.length),
offset: Number(res?.offset ?? currentOffset),
limit: Number(res?.limit ?? currentLimit),
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 = {
/** Sets the active search filter. */
filter(query) {
automat.setState({ filter: String(query ?? '') });
},
/**
* Loads all orders, deduplicating concurrent callers.
*
* @returns {Promise<Order[]>}
*/
load() {
if (inflightLoad) return inflightLoad;
automat.setState({ status: 'loading', error: null });
inflightLoad = fetchAllOrders()
.then((orders) => {
automat.setState(applyAll(orders));
return orders;
} catch (error) {
})
.catch((error) => {
automat.setState({ status: 'failed', error });
throw error;
} finally {
inflightLoad = null;
}
})();
})
.finally(() => { inflightLoad = null; });
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[]>}
*/
async reload() {
const { offset, limit } = automat.state;
try {
const res = await orderApi.list({ offset, limit });
const orders = res?.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,
});
const orders = await fetchAllOrders();
automat.setState(applyAll(orders));
return orders;
} catch (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.

View File

@@ -1,33 +1,23 @@
import { Fragment, PureComponent } from 'react';
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 Collapse from '@mui/material/Collapse';
import IconButton from '@mui/material/IconButton';
import Stack from '@mui/material/Stack';
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 ListItemButton from '@mui/material/ListItemButton';
import Typography from '@mui/material/Typography';
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 BackendError from '../BackendError';
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
import FillSpace from 'dashgui/FillSpace';
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 { backendErrorFrom } from '../../utils/backendError';
import { notifyError } from '../../utils/notify';
import { formatPrice } from '../../utils/price';
import withNavigate from '../withNavigate';
import ResultsPagination from '../article/ResultsPagination';
import {
formatDate,
loadParties,
@@ -36,530 +26,293 @@ import {
partyDisplayName,
} from './manifestConfig';
function tintDivider(color, amount = 0.15) {
if (typeof color !== 'string') return 'divider';
const m = color.trim().match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
if (!m) return 'divider';
// 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);
}
let hex = m[1];
if (hex.length === 3) {
hex = hex
.split('')
.map((c) => c + c)
.join('');
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 };
// MARK: one row rendered by FillSpace
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 } = rowContext;
const lines = order.lines ?? [];
const gross = orderGrossTotal(lines);
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={{ flex: 1, minWidth: 0 }}>
<Typography variant="body1" sx={{ fontWeight: 500, ...ellipsisSx }}>
{this.partyName(order.entityId)}
</Typography>
<Typography variant="body2" color="text.secondary" sx={ellipsisSx}>
{cfg.dateLabel} {formatDate(order.date)}
</Typography>
</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>
);
}
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) => {
const amount = Number(line.amount) || 0;
const net = Number(line.net) || 0;
const rebate = Number(line.rebate) || 0;
const lineTotal = amount * net * (1 - rebate / 100);
return acc + lineTotal;
}, 0);
}
function calculateOrderGrossTotal(lines) {
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);
// 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() {
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: list page
export class OrderList extends PureComponent {
constructor(props) {
super(props);
this.cfg = manifestConfig('order');
const orderState = orderAutomat.state;
const s = orderAutomat.state;
this.state = {
status: orderState.status === 'ready' ? 'ready' : 'loading',
error: orderState.error,
orders: orderState.items ?? [],
total: orderState.total ?? 0,
offset: orderState.offset ?? 0,
limit: orderState.limit ?? 20,
status: s.status === 'ready' ? 'ready' : 'loading',
error: s.error ? backendErrorFrom(s.error) : null,
items: s.items ?? [],
filter: s.filter ?? '',
partiesById: {},
busyId: null,
expandedIds: new Set(),
};
this.searchRef = createRef();
this._cancelled = false;
this.handleRetry = this.handleRetry.bind(this);
this.handleCreate = this.handleCreate.bind(this);
this.handleEdit = this.handleEdit.bind(this);
this.handleOpen = this.handleOpen.bind(this);
this.handleDelete = this.handleDelete.bind(this);
this.handlePageChange = this.handlePageChange.bind(this);
this.toggleExpand = this.toggleExpand.bind(this);
this.nameOf = this.nameOf.bind(this);
}
componentDidMount() {
this._cancelled = false;
this.unsubOrder = orderAutomat.subscribe(this, (state) => ({
orders: state.items ?? [],
total: state.total ?? 0,
offset: state.offset ?? 0,
limit: state.limit ?? 20,
this.unsub = orderAutomat.subscribe(this, (state) => ({
items: state.items ?? [],
filter: state.filter ?? '',
status: state.status,
error: state.error,
error: state.error ? backendErrorFrom(state.error) : null,
}));
void this.loadPartiesAndOrders();
void this.loadAll();
}
componentWillUnmount() {
this._cancelled = true;
this.unsubOrder?.();
this.unsub?.();
}
async loadPartiesAndOrders() {
async loadAll() {
try {
const parties = await loadParties('order');
if (this._cancelled) return;
this.setState({
partiesById: this.cfg.partiesByIdFromList(parties),
});
if (!this._cancelled) this.setState({ partiesById: cfg.partiesByIdFromList(parties) });
} catch {
// Non-fatal, party names can fall back to IDs
// Non-fatal: names fall back to ids.
}
try {
await orderAutomat.actions.ensureLoaded();
} catch (err) {
if (this._cancelled) return;
this.setState({ status: 'failed', error: backendErrorFrom(err) });
} catch {
// Surfaced through automat state.
}
}
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) });
}
nameOf(order) {
const party = this.state.partiesById[order.entityId];
return partyDisplayName('order', party);
}
go(to) {
const { navigate } = this.props;
if (typeof navigate === 'function') {
navigate(to);
return;
}
window.location.assign(to);
handleRetry() {
void orderAutomat.actions.load().catch(() => {});
}
handleCreate() {
this.go(this.cfg.createPath);
this.props.navigate(cfg.createPath);
}
handleEdit(order) {
this.go(this.cfg.editPath(order.id));
handleOpen(order) {
this.props.navigate(cfg.editPath(order.id));
}
async handleDelete(order) {
if (this.state.busyId != null) return;
if (!window.confirm(this.cfg.confirmDelete(order.number))) return;
if (!window.confirm(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' });
enqueueSnackbar(cfg.deletedMsg, { variant: 'success' });
} catch (err) {
if (this._cancelled) return;
notifyError(err);
this.setState({ busyId: null });
} finally {
if (!this._cancelled) this.setState({ busyId: null });
}
}
handlePageChange(page) {
// Formula: Next Offset: (page - 1) * limit
void orderAutomat.actions.setPage(page);
}
render() {
const { status, error, items, filter, partiesById, busyId } = this.state;
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 };
});
}
partyName(partyId) {
if (partyId == null) return '–';
const party = this.state.partiesById[partyId];
return partyDisplayName('order', party) || `Kunde #${partyId}`;
}
renderLoading() {
if (status === 'loading' || status === 'idle') {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
<CircularProgress />
</Box>
);
}
renderLineItems(order, borderColor) {
const lines = order.lines ?? [];
if (lines.length === 0) {
return (
<Box sx={{ py: 1.5, px: 2, bgcolor: 'action.hover', borderRadius: 1 }}>
<Typography variant="body2" color="text.secondary">
Keine Positionen vorhanden.
</Typography>
</Box>
);
}
const netTotal = calculateOrderTotal(lines);
const grossTotal = calculateOrderGrossTotal(lines);
return (
<Box
sx={{
py: 1,
px: 1.5,
bgcolor: 'rgba(255, 255, 255, 0.45)',
borderRadius: 1,
border: '1px solid',
borderColor,
}}
>
<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 (
<TableRow key={line.id ?? `line-${idx}`}>
<TableCell align="center" sx={{ fontSize: '0.75rem', color: 'text.secondary', fontWeight: 600 }}>
{idx + 1}
</TableCell>
<TableCell sx={{ fontFamily: 'monospace', fontSize: '0.75rem' }}>
{line.sku || '–'}
</TableCell>
<TableCell sx={{ fontSize: '0.75rem' }}>
<Typography variant="body2" sx={{ fontSize: '0.75rem', fontWeight: 500 }}>
{line.name || '–'}
</Typography>
{line.comment ? (
<Typography variant="caption" color="text.secondary">
{line.comment}
</Typography>
) : null}
</TableCell>
<TableCell align="right" sx={{ fontSize: '0.75rem' }}>
{amount}
{puText}
</TableCell>
<TableCell align="right" sx={{ fontSize: '0.75rem' }}>
{formatPrice(net)}
</TableCell>
<TableCell align="right" sx={{ fontSize: '0.75rem' }}>
{formatPrice(gross)}
</TableCell>
<TableCell align="right" sx={{ fontSize: '0.75rem' }}>
{rebate > 0 ? `${rebate}%` : '–'}
</TableCell>
<TableCell align="right" sx={{ fontSize: '0.75rem' }}>
{line.taxrate != null ? `${line.taxrate}%` : '–'}
</TableCell>
<TableCell align="right" sx={{ fontSize: '0.75rem', fontWeight: 600 }}>
{formatPrice(lineNet)}
</TableCell>
<TableCell align="right" sx={{ fontSize: '0.75rem', fontWeight: 600, color: 'primary.dark' }}>
{formatPrice(lineGross)}
</TableCell>
</TableRow>
);
})}
</TableBody>
<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>
);
}
render() {
const { status, error, orders, total, offset, limit, busyId, expandedIds } = this.state;
const cfg = this.cfg;
if (status === 'loading') return this.renderLoading();
if (status === 'failed') {
return (
<BackendError
error={error}
onRetry={this.handleRetry}
/>
);
return <BackendError error={error} onRetry={this.handleRetry} />;
}
const borderColor = tintDivider(cfg.listColor);
rowContext.partiesById = partiesById;
rowContext.busyId = busyId;
rowContext.onOpen = this.handleOpen;
rowContext.onDelete = this.handleDelete;
// 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;
const rows = filterOrders(items, filter, this.nameOf);
return (
<>
<OrderSearchField inputRef={this.searchRef} onCreate={this.handleCreate} />
<PanelTileGrid>
<PanelTile wide={3} minHeight={240} color={cfg.listColor}>
<Stack spacing={2}>
{/* Create Header Button */}
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button
type="button"
variant="contained"
size="small"
onClick={this.handleCreate}
>
{cfg.createButton}
</Button>
</Box>
{/* Empty State */}
{orders.length === 0 ? (
<Typography variant="body2" color="text.secondary">
<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>
) : (
<TableContainer>
<Table
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,
}}
<FillSpace
aboveRef={[this.searchRef]}
reserveBottom={32}
minPageSize={8}
minHeight={0}
items={rows}
totalCount={items.length}
renderItem={OrderRow}
/>
{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>
</>
);
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -190,7 +190,7 @@ async function main() {
await new Promise((resolve, reject) => {
const build = spawn('npx', ['vite', 'build'], {
cwd: FRONT_ROOT,
env: { ...process.env },
env: { ...process.env, NODE_ENV: 'production' },
stdio: ['ignore', 'pipe', 'pipe'],
});
build.stdout.on('data', () => process.stdout.write('[build] '));
@@ -208,7 +208,7 @@ async function main() {
const server = startProc(
'npx',
['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',
);
await waitForPort(VITE_PORT);
@@ -248,6 +248,7 @@ async function main() {
height: viewport.height,
settleMs: SETTLE_MS,
dialog: route.dialog,
icons: route.icons,
});
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
* 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.
const target = await httpJson(`http://127.0.0.1:${debugPort}/json/new?${encodeURIComponent(url)}`, {
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.
await session.send('Runtime.evaluate', {
expression: `(() => { const s = document.createElement('style');

View File

@@ -20,6 +20,8 @@ export const ROUTES = [
{ name: 'vorlagen', path: '/artikel/vorlagen' },
{ name: 'merkmale', path: '/artikel/merkmale' },
{ 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-neu', path: '/lieferung/neu' },
{ 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