diff --git a/src/components/manifest/QuoteList.jsx b/src/components/manifest/QuoteList.jsx
new file mode 100644
index 0000000..e474594
--- /dev/null
+++ b/src/components/manifest/QuoteList.jsx
@@ -0,0 +1,553 @@
+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 Skeleton from '@mui/material/Skeleton';
+import Tooltip from '@mui/material/Tooltip';
+import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
+import ViewModuleIcon from '@mui/icons-material/ViewModule';
+import { enqueueSnackbar } from 'notistack';
+import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
+import FillSpace from 'dashgui/FillSpace';
+import { PanelTile, PanelTileGrid } from 'dashgui/panels';
+import BackendError from '../BackendError';
+import quoteAutomat, { CHUNK_SIZE } from '../../automats/quoteAutomat';
+import { quote as quoteApi } 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 quoteGrossTotal(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('quote');
+
+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 `QuoteRow`: FillSpace renders rows as bare elements
+ * (`
`), so the list publishes callbacks and the party map
+ * here on every render.
+ */
+const rowContext = {
+ partiesById: {},
+ busyId: null,
+ onOpen: null,
+ onDelete: null,
+};
+
+// MARK:
+class QuoteRow extends PureComponent {
+ partyName(partyId) {
+ if (partyId == null) return '–';
+ const party = rowContext.partiesById[partyId];
+ return partyDisplayName('quote', party) || `Kunde #${partyId}`;
+ }
+
+ render() {
+ const { item: quote, disabled } = this.props;
+
+ // Data-free probe (FillSpace measures the row height with `item` undefined).
+ if (!quote) {
+ if (disabled) {
+ return (
+
+
+
+ );
+ }
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+ const { busyId, onOpen, onDelete } = rowContext;
+ const posCount = quote.positions != null ? quote.positions : (quote.lines?.length ?? 0);
+ const gross = quote.grossTotal != null ? Number(quote.grossTotal) : quoteGrossTotal(quote.lines);
+ const customer = quote.customerName || this.partyName(quote.entityId);
+ const quantityInfo = quote.totalQuantity != null ? ` · ${quote.totalQuantity} Stück` : '';
+
+ return (
+ onOpen?.(quote)}>
+
+
+ {quote.number}
+
+
+ {numberDisplay(quote.externalNumber)}
+
+
+
+
+
+ {quote.date ? formatDate(quote.date) : '–'}
+
+
+
+
+
+ {customer}
+
+
+
+ 0 ? 'text.primary' : 'text.disabled',
+ '& .MuiChip-label': { px: 0.5 },
+ }}
+ />
+
+
+ {gross > 0 ? formatPrice(gross) : '—'}
+
+
+ {
+ e.stopPropagation();
+ onDelete?.(quote);
+ }}
+ onMouseDown={(e) => e.stopPropagation()}
+ sx={{ width: 40, height: 40, flexShrink: 0 }}
+ >
+
+
+
+ );
+ }
+}
+
+// MARK:
+class QuoteSearchField extends PureComponent {
+ constructor(props) {
+ super(props);
+ const s = quoteAutomat.state;
+ const stats = quoteAutomat.getChunkStats?.();
+ this.state = {
+ input: s.filter ?? '',
+ seekMonths: s.seekMonths ?? 0,
+ seekYears: s.seekYears ?? 0,
+ negativeSeekMonths: s.negativeSeekMonths ?? 0,
+ lruChunksCount: stats?.lruChunksCount || 0,
+ maxLruChunks: stats?.maxLruChunks || 50,
+ filterCachesCount: stats?.filterCaches?.length || 1,
+ };
+ }
+
+ componentDidMount() {
+ this.unsub = quoteAutomat.subscribe(this, (state) => ({
+ input: state.filter ?? '',
+ seekMonths: state.seekMonths ?? 0,
+ seekYears: state.seekYears ?? 0,
+ negativeSeekMonths: state.negativeSeekMonths ?? 0,
+ }));
+ if (import.meta.env.DEV) {
+ this.unsubStats = quoteAutomat.subscribeChunkStats?.((stats) => {
+ this.setState({
+ lruChunksCount: stats.lruChunksCount,
+ maxLruChunks: stats.maxLruChunks,
+ filterCachesCount: stats.filterCaches?.length || 1,
+ });
+ });
+ }
+ }
+
+ componentWillUnmount() {
+ this.unsub?.();
+ this.unsubStats?.();
+ }
+
+ render() {
+ const { inputRef, onCreate } = this.props;
+ const { input, 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 (
+
+
+ {/* Top row: search field & create button */}
+
+ quoteAutomat.actions.filter(next)}
+ />
+
+
+
+ {/* Filter Bar: Negativeseek (-1 Month, -1 Year, Reset) & Chunk-Cache Monitor */}
+
+ {/* Negativeseek controls (-1 Month, -1 Year, Reset) */}
+
+
+
+
+
+
+
+
+
+ {import.meta.env.DEV && (
+
+ }
+ label={this.state.lruChunksCount > 0 ? `Chunk-Cache (${this.state.lruChunksCount}/${this.state.maxLruChunks || 50})` : 'Chunk-Cache'}
+ onClick={() => window.dispatchEvent(new CustomEvent('dash:toggle-chunk-monitor', { detail: { target: 'quote' } }))}
+ sx={{
+ cursor: 'pointer',
+ height: 28,
+ fontWeight: 600,
+ fontSize: '0.75rem',
+ bgcolor: 'background.paper',
+ boxShadow: 1,
+ '&:hover': { bgcolor: 'action.hover' },
+ }}
+ />
+
+ )}
+
+
+
+
+ );
+ }
+}
+
+// MARK:
+export class QuoteList extends PureComponent {
+ constructor(props) {
+ super(props);
+ this.state = {
+ items: quoteAutomat.getItems(),
+ unfilteredTotal: quoteAutomat.state.unfilteredTotal ?? 0,
+ filterKey: quoteAutomat.getFilterKey?.() ?? '',
+ 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.handlePageChange = this.handlePageChange.bind(this);
+ }
+
+ componentDidMount() {
+ this._cancelled = false;
+ this.unsub = quoteAutomat.subscribe(this, (state) => ({
+ items: state.items,
+ unfilteredTotal: state.unfilteredTotal ?? 0,
+ filterKey: quoteAutomat.getFilterKey(state),
+ }));
+ void this.loadParties();
+ }
+
+ componentWillUnmount() {
+ this._cancelled = true;
+ this.unsub?.();
+ }
+
+ async loadParties() {
+ try {
+ const parties = await loadParties('quote');
+ if (!this._cancelled) {
+ this.setState({ partiesById: cfg.partiesByIdFromList(parties) });
+ }
+ } catch {
+ // Non-fatal: names fall back to ids or customerName.
+ }
+ }
+
+ 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);
+
+ quoteAutomat.requireChunk(startChunk, endChunk);
+ }
+
+ handleRetry() {
+ void quoteAutomat.actions.requireChunk(0, 0, { immediate: true }).catch(() => {});
+ }
+
+ handleCreate() {
+ this.props.navigate(cfg.createPath);
+ }
+
+ handleOpen(quote) {
+ this.props.navigate(cfg.editPath(quote.id));
+ }
+
+ async handleDelete(quote) {
+ if (this.state.busyId != null) return;
+ if (!window.confirm(cfg.confirmDelete(quote.number))) return;
+ this.setState({ busyId: quote.id });
+ try {
+ await quoteApi.delete(quote.id);
+ await quoteAutomat.actions.reload();
+ enqueueSnackbar(cfg.deletedMsg, { variant: 'success' });
+ } catch (err) {
+ notifyError(err);
+ } finally {
+ if (!this._cancelled) this.setState({ busyId: null });
+ }
+ }
+
+ render() {
+ const { items, unfilteredTotal, filterKey, partiesById, busyId } = this.state;
+
+ rowContext.partiesById = partiesById;
+ rowContext.busyId = busyId;
+ rowContext.onOpen = this.handleOpen;
+ rowContext.onDelete = this.handleDelete;
+
+ if (items === null && quoteAutomat.state.status === 'failed') {
+ return (
+ <>
+
+
+
+
+
+
+ >
+ );
+ }
+
+ return (
+ <>
+
+
+
+
+ Nr. / Ref.
+ Datum
+ Kunde
+ Pos.
+ Gesamt
+
+
+ }
+ onPageChange={this.handlePageChange}
+ />
+ {items === null && (
+
+
+
+ )}
+
+
+ >
+ );
+ }
+}
+
+export default withNavigate(QuoteList);
diff --git a/src/components/manifest/index.js b/src/components/manifest/index.js
index 098ce8e..b39dd73 100644
--- a/src/components/manifest/index.js
+++ b/src/components/manifest/index.js
@@ -1,6 +1,7 @@
export { default as ManifestList } from './ManifestList';
export { default as DeliveryList } from './DeliveryList';
export { default as OrderList } from './OrderList';
+export { default as QuoteList } from './QuoteList';
export { default as ShipmentList } from './ShipmentList';
export { default as OrderPaymentList } from './OrderPaymentList';
diff --git a/src/devtools/ChunkCacheMonitor.jsx b/src/devtools/ChunkCacheMonitor.jsx
index 9528b31..ada2dfe 100644
--- a/src/devtools/ChunkCacheMonitor.jsx
+++ b/src/devtools/ChunkCacheMonitor.jsx
@@ -37,10 +37,23 @@ import orderAutomat, {
MAX_LRU_CHUNKS,
isOrderPaid,
isOrderDelivered,
- subscribeChunkStats,
} from '../automats/orderAutomat';
+import quoteAutomat from '../automats/quoteAutomat';
import { formatPrice } from '../utils/price';
+function getAutomatFor(target) {
+ return target === 'quote' ? quoteAutomat : orderAutomat;
+}
+
+function getInitialTarget() {
+ if (typeof window !== 'undefined') {
+ const p = window.location.pathname;
+ if (p.startsWith('/auftrag/bestellung')) return 'order';
+ if (p.startsWith('/auftrag')) return 'quote';
+ }
+ return 'order';
+}
+
const PANEL_WIDTH = 680;
const PANEL_HEIGHT = 520;
@@ -72,7 +85,9 @@ function orderGrossTotal(lines) {
class ChunkCacheMonitor extends PureComponent {
constructor(props) {
super(props);
- const initialStats = orderAutomat.getChunkStats?.() || {
+ const target = getInitialTarget();
+ const automat = getAutomatFor(target);
+ const initialStats = automat.getChunkStats?.() || {
chunkSize: CHUNK_SIZE,
maxLruChunks: MAX_LRU_CHUNKS,
lruChunksCount: 0,
@@ -100,6 +115,7 @@ class ChunkCacheMonitor extends PureComponent {
open: false,
position: null,
tab: 'grid', // 'grid' | 'lru' | 'log'
+ target, // 'quote' | 'order'
inspectFilterKey: null, // null means follow activeFilterKey
selectedChunk: null,
pulseCounter: 0,
@@ -121,10 +137,12 @@ class ChunkCacheMonitor extends PureComponent {
this.handleApplyFilter = this.handleApplyFilter.bind(this);
this.handlePrefetchAll = this.handlePrefetchAll.bind(this);
this.handleReload = this.handleReload.bind(this);
+ this.switchTarget = this.switchTarget.bind(this);
}
componentDidMount() {
- this.unsubscribeStats = subscribeChunkStats((stats) => {
+ const automat = getAutomatFor(this.state.target);
+ this.unsubscribeStats = automat.subscribeChunkStats?.((stats) => {
this.setState((prev) => ({
stats,
pulseCounter: prev.pulseCounter + 1,
@@ -132,10 +150,39 @@ class ChunkCacheMonitor extends PureComponent {
});
window.addEventListener('keydown', this.handleKeyDown);
- this.customEventListener = () => this.handleToggle();
+ this.customEventListener = (event) => {
+ const reqTarget = event?.detail?.target;
+ if (reqTarget === 'quote' || reqTarget === 'order') {
+ this.switchTarget(reqTarget);
+ } else {
+ const detected = getInitialTarget();
+ if (detected !== this.state.target) {
+ this.switchTarget(detected);
+ }
+ }
+ this.handleToggle();
+ };
window.addEventListener('dash:toggle-chunk-monitor', this.customEventListener);
}
+ switchTarget(newTarget) {
+ if (newTarget === this.state.target) return;
+ this.unsubscribeStats?.();
+ const automat = getAutomatFor(newTarget);
+ this.unsubscribeStats = automat.subscribeChunkStats?.((stats) => {
+ this.setState((prev) => ({
+ stats,
+ pulseCounter: prev.pulseCounter + 1,
+ }));
+ });
+ this.setState({
+ target: newTarget,
+ stats: automat.getChunkStats?.() || this.state.stats,
+ selectedChunk: null,
+ inspectFilterKey: null,
+ });
+ }
+
componentWillUnmount() {
this.unsubscribeStats?.();
window.removeEventListener('keydown', this.handleKeyDown);
@@ -204,51 +251,60 @@ class ChunkCacheMonitor extends PureComponent {
handleLoadChunk(chunkIndex, filterKey, e) {
e?.stopPropagation();
+ const automat = getAutomatFor(this.state.target);
const targetFilter = filterKey || this.getInspectedFilterKey();
- void orderAutomat.actions.loadChunk(chunkIndex, orderAutomat.state.queryEpoch, targetFilter);
+ void automat.actions.loadChunk(chunkIndex, automat.state.queryEpoch, targetFilter);
}
handleEvictChunk(chunkIndex, filterKey, e) {
e?.stopPropagation();
+ const automat = getAutomatFor(this.state.target);
const targetFilter = filterKey || this.getInspectedFilterKey();
- orderAutomat.evictChunk?.(chunkIndex, targetFilter);
+ automat.evictChunk?.(chunkIndex, targetFilter);
if (this.state.selectedChunk === chunkIndex) {
this.setState({ selectedChunk: null });
}
}
handleClearCache() {
- orderAutomat.clearCache?.();
+ const automat = getAutomatFor(this.state.target);
+ automat.clearCache?.();
this.setState({ selectedChunk: null });
}
handleClearFilter(filterKey, e) {
e?.stopPropagation();
- orderAutomat.clearFilterCache?.(filterKey);
+ const automat = getAutomatFor(this.state.target);
+ automat.clearFilterCache?.(filterKey);
if (this.state.inspectFilterKey === filterKey) {
this.setState({ inspectFilterKey: null, selectedChunk: null });
}
}
handleApplyFilter(filterKey) {
+ const automat = getAutomatFor(this.state.target);
const entry = this.state.stats.filterCaches?.find((f) => f.key === filterKey);
if (!entry) return;
const p = entry.params || {};
- orderAutomat.actions.resetFilters();
- if (p.filter) orderAutomat.actions.filter(p.filter);
- if (p.onlyUnpaid) orderAutomat.actions.setOnlyUnpaid(true);
- if (p.onlyUndelivered) orderAutomat.actions.setOnlyUndelivered(true);
- if (p.negativeSeekMonths) orderAutomat.actions.addNegativeSeek(p.negativeSeekMonths);
+ automat.actions.resetFilters();
+ if (p.filter) automat.actions.filter(p.filter);
+ if (this.state.target === 'order') {
+ if (p.onlyUnpaid) automat.actions.setOnlyUnpaid(true);
+ if (p.onlyUndelivered) automat.actions.setOnlyUndelivered(true);
+ }
+ if (p.negativeSeekMonths) automat.actions.addNegativeSeek(p.negativeSeekMonths);
this.setState({ inspectFilterKey: null, selectedChunk: null });
}
handlePrefetchAll(filterKey) {
+ const automat = getAutomatFor(this.state.target);
const targetFilter = filterKey || this.getInspectedFilterKey();
- orderAutomat.prefetchAllChunks?.(targetFilter);
+ automat.prefetchAllChunks?.(targetFilter);
}
handleReload() {
- void orderAutomat.actions.reload(false);
+ const automat = getAutomatFor(this.state.target);
+ void automat.actions.reload(false);
this.setState({ selectedChunk: null });
}
@@ -658,9 +714,12 @@ class ChunkCacheMonitor extends PureComponent {
) : (
{chunkOrders.map(({ item: order, index }) => {
- const paid = isOrderPaid(order);
- const delivered = isOrderDelivered(order);
- const gross = orderGrossTotal(order.lines);
+ const isOrd = this.state.target === 'order';
+ const paid = isOrd ? isOrderPaid(order) : false;
+ const delivered = isOrd ? isOrderDelivered(order) : false;
+ const gross = order.grossTotal != null ? Number(order.grossTotal) : orderGrossTotal(order.lines);
+ const customer = order.customerName || (order.entityId ? `Kunde #${order.entityId}` : '–');
+ const posCount = order.positions != null ? order.positions : (order.lines?.length ?? 0);
return (
- Kunde #{order.entityId || '–'}
+ {customer}
-
+ {isOrd && (
+ <>
+
+
+ >
+ )}
-
+
+ this.switchTarget('quote')}
+ sx={{ cursor: 'pointer', height: 24, fontSize: '0.72rem', fontWeight: this.state.target === 'quote' ? 700 : 500 }}
+ />
+ this.switchTarget('order')}
+ sx={{ cursor: 'pointer', height: 24, fontSize: '0.72rem', fontWeight: this.state.target === 'order' ? 700 : 500 }}
+ />
+
= maxLru ? 'warning' : 'success'}
diff --git a/src/routes/auftrag.jsx b/src/routes/auftrag.jsx
index 9954b57..8ed7ddb 100644
--- a/src/routes/auftrag.jsx
+++ b/src/routes/auftrag.jsx
@@ -1,9 +1,9 @@
import { PureComponent } from 'react';
-import { ManifestList } from '../components/manifest';
+import QuoteList from '../components/manifest/QuoteList';
class AuftragAngebotPage extends PureComponent {
render() {
- return ;
+ return ;
}
}