This commit is contained in:
seb
2026-10-19 13:24:39 +02:00
parent 3cec22e4e5
commit 883c856bdb
12 changed files with 943 additions and 275 deletions

View File

@@ -12,13 +12,14 @@ export const order = {
* → { orders: Order[], total: number, offset: number, limit: number }
*/
// #region GET /api/order — list
list({ offset = 0, limit = 50, search, onlyUnpaid, onlyUndelivered, seekMonths } = {}) {
list({ offset = 0, limit = 50, search, onlyUnpaid, onlyUndelivered, seekMonths, entityId } = {}) {
const params = new URLSearchParams();
if (offset != null) params.set('offset', String(Math.max(0, Math.floor(Number(offset) || 0))));
if (limit != null) params.set('limit', String(Math.max(1, Math.floor(Number(limit) || 50))));
if (search && String(search).trim()) params.set('search', String(search).trim());
if (onlyUnpaid === true || onlyUnpaid === 'true') params.set('onlyUnpaid', 'true');
if (onlyUndelivered === true || onlyUndelivered === 'true') params.set('onlyUndelivered', 'true');
if (entityId != null) params.set('entityId', String(entityId));
if (seekMonths != null && Number(seekMonths) < 0) {
params.set('seekMonths', String(Math.floor(Number(seekMonths))));
}

View File

@@ -13,11 +13,12 @@ export const quote = {
* → { quotes: QuoteSummary[], total: number, unfilteredTotal: number, offset: number, limit: number }
*/
// #region GET /api/quote — list
list({ offset = 0, limit = 50, search, seekMonths } = {}) {
list({ offset = 0, limit = 50, search, seekMonths, entityId } = {}) {
const params = new URLSearchParams();
if (offset != null) params.set('offset', String(Math.max(0, Math.floor(Number(offset) || 0))));
if (limit != null) params.set('limit', String(Math.max(1, Math.floor(Number(limit) || 50))));
if (search && String(search).trim()) params.set('search', String(search).trim());
if (entityId != null) params.set('entityId', String(entityId));
if (seekMonths != null && Number(seekMonths) < 0) {
params.set('seekMonths', String(Math.floor(Number(seekMonths))));
}

View File

@@ -13,6 +13,7 @@ export const shipment = {
if (params.search) q.set('search', String(params.search));
if (params.state) q.set('state', String(params.state));
if (params.orderId != null) q.set('orderId', String(params.orderId));
if (params.entityId != null) q.set('entityId', String(params.entityId));
if (params.seekMonths != null) q.set('seekMonths', String(params.seekMonths));
const qs = q.toString();
return http(`/api/shipment${qs ? `?${qs}` : ''}`);

View File

@@ -69,6 +69,7 @@ const initialState = {
filter: '',
onlyUnpaid: false,
onlyUndelivered: false,
entityId: null,
seekMonths: 0,
seekYears: 0,
negativeSeekMonths: 0,
@@ -117,25 +118,27 @@ export function getFilterParams(state = automat?.state || initialState) {
const filter = String(state.filter ?? '').trim();
const onlyUnpaid = Boolean(state.onlyUnpaid);
const onlyUndelivered = Boolean(state.onlyUndelivered);
const entityId = state.entityId != null && state.entityId !== '' ? Number(state.entityId) : null;
const negativeSeekMonths = (state.negativeSeekMonths != null && Number(state.negativeSeekMonths) < 0)
? Number(state.negativeSeekMonths)
: 0;
return { filter, onlyUnpaid, onlyUndelivered, negativeSeekMonths };
return { filter, onlyUnpaid, onlyUndelivered, entityId, negativeSeekMonths };
}
export function getFilterKey(state = automat?.state || initialState) {
const { filter, onlyUnpaid, onlyUndelivered, negativeSeekMonths } = getFilterParams(state);
return JSON.stringify({ f: filter, u: onlyUnpaid, d: onlyUndelivered, s: negativeSeekMonths });
const { filter, onlyUnpaid, onlyUndelivered, entityId, negativeSeekMonths } = getFilterParams(state);
return JSON.stringify({ f: filter, u: onlyUnpaid, d: onlyUndelivered, e: entityId, s: negativeSeekMonths });
}
export function formatFilterLabel(filterKeyOrState) {
let f, u, d, s;
let f, u, d, e, s;
if (typeof filterKeyOrState === 'string') {
try {
const parsed = JSON.parse(filterKeyOrState);
f = parsed.f;
u = parsed.u;
d = parsed.d;
e = parsed.e;
s = parsed.s;
} catch {
return filterKeyOrState;
@@ -145,18 +148,21 @@ export function formatFilterLabel(filterKeyOrState) {
f = filterKeyOrState.f;
u = filterKeyOrState.u;
d = filterKeyOrState.d;
e = filterKeyOrState.e;
s = filterKeyOrState.s;
} else {
const params = getFilterParams(filterKeyOrState);
f = params.filter;
u = params.onlyUnpaid;
d = params.onlyUndelivered;
e = params.entityId;
s = params.negativeSeekMonths;
}
}
const parts = [];
if (f) parts.push(`Suche: „${f}“`);
if (e != null) parts.push(`Kunde: #${e}`);
if (u) parts.push('Nur unbezahlt');
if (d) parts.push('Nur ungeliefert');
if (s < 0) parts.push(`Seek: ${formatNegativeSeek(s)}`);
@@ -498,6 +504,7 @@ function buildQueryParamsFor(filterParams, offset = 0, limit = CHUNK_SIZE) {
if (trimmed) params.search = trimmed;
if (filterParams.onlyUnpaid) params.onlyUnpaid = true;
if (filterParams.onlyUndelivered) params.onlyUndelivered = true;
if (filterParams.entityId != null) params.entityId = filterParams.entityId;
if (filterParams.negativeSeekMonths != null && Number(filterParams.negativeSeekMonths) < 0) {
params.seekMonths = Number(filterParams.negativeSeekMonths);
}
@@ -859,6 +866,12 @@ const actions = {
onFilterChange(true);
},
setEntityId(entityId) {
const parsed = entityId != null && entityId !== '' ? Number(entityId) : null;
automat.setState({ entityId: parsed });
onFilterChange(true);
},
addSeekMonth() {
const seekMonths = (automat.state.seekMonths ?? 0) + 1;
const seekYears = automat.state.seekYears ?? 0;

View File

@@ -70,6 +70,7 @@ const initialState = {
total: 0,
unfilteredTotal: 0,
filter: '',
entityId: null,
seekMonths: 0,
seekYears: 0,
negativeSeekMonths: 0,
@@ -112,23 +113,25 @@ export function parseLruKey(key) {
// MARK: pure filter helpers
export function getFilterParams(state = automat?.state || initialState) {
const filter = String(state.filter ?? '').trim();
const entityId = state.entityId != null && state.entityId !== '' ? Number(state.entityId) : null;
const negativeSeekMonths = (state.negativeSeekMonths != null && Number(state.negativeSeekMonths) < 0)
? Number(state.negativeSeekMonths)
: 0;
return { filter, negativeSeekMonths };
return { filter, entityId, negativeSeekMonths };
}
export function getFilterKey(state = automat?.state || initialState) {
const { filter, negativeSeekMonths } = getFilterParams(state);
return JSON.stringify({ f: filter, s: negativeSeekMonths });
const { filter, entityId, negativeSeekMonths } = getFilterParams(state);
return JSON.stringify({ f: filter, e: entityId, s: negativeSeekMonths });
}
export function formatFilterLabel(filterKeyOrState) {
let f, s;
let f, e, s;
if (typeof filterKeyOrState === 'string') {
try {
const parsed = JSON.parse(filterKeyOrState);
f = parsed.f;
e = parsed.e;
s = parsed.s;
} catch {
return filterKeyOrState;
@@ -136,16 +139,19 @@ export function formatFilterLabel(filterKeyOrState) {
} else if (filterKeyOrState && typeof filterKeyOrState === 'object') {
if ('f' in filterKeyOrState) {
f = filterKeyOrState.f;
e = filterKeyOrState.e;
s = filterKeyOrState.s;
} else {
const params = getFilterParams(filterKeyOrState);
f = params.filter;
e = params.entityId;
s = params.negativeSeekMonths;
}
}
const parts = [];
if (f) parts.push(`Suche: „${f}“`);
if (e != null) parts.push(`Kunde: #${e}`);
if (s < 0) parts.push(`Seek: ${formatNegativeSeek(s)}`);
if (parts.length === 0) return 'Standard (Alle Angebote)';
return parts.join(' · ');
@@ -439,6 +445,7 @@ function buildQueryParamsFor(filterParams, offset = 0, limit = CHUNK_SIZE) {
const params = { offset, limit };
const trimmed = String(filterParams.filter ?? '').trim();
if (trimmed) params.search = trimmed;
if (filterParams.entityId != null) params.entityId = filterParams.entityId;
if (filterParams.negativeSeekMonths != null && Number(filterParams.negativeSeekMonths) < 0) {
params.seekMonths = Number(filterParams.negativeSeekMonths);
}
@@ -777,6 +784,12 @@ const actions = {
onFilterChange(false);
},
setEntityId(entityId) {
const parsed = entityId != null && entityId !== '' ? Number(entityId) : null;
automat.setState({ entityId: parsed });
onFilterChange(true);
},
addSeekMonth() {
const seekMonths = (automat.state.seekMonths ?? 0) + 1;
const seekYears = automat.state.seekYears ?? 0;

View File

@@ -64,6 +64,7 @@ const initialState = {
unfilteredTotal: 0,
filter: '',
stateFilter: '',
entityId: null,
seekMonths: 0,
seekYears: 0,
negativeSeekMonths: 0,
@@ -107,15 +108,16 @@ export function parseLruKey(key) {
export function getFilterParams(state = automat?.state || initialState) {
const filter = String(state.filter ?? '').trim();
const stateFilter = String(state.stateFilter ?? '').trim();
const entityId = state.entityId != null && state.entityId !== '' ? Number(state.entityId) : null;
const negativeSeekMonths = (state.negativeSeekMonths != null && Number(state.negativeSeekMonths) < 0)
? Number(state.negativeSeekMonths)
: 0;
return { filter, stateFilter, negativeSeekMonths };
return { filter, stateFilter, entityId, negativeSeekMonths };
}
export function getFilterKey(state = automat?.state || initialState) {
const { filter, stateFilter, negativeSeekMonths } = getFilterParams(state);
return JSON.stringify({ f: filter, st: stateFilter, s: negativeSeekMonths });
const { filter, stateFilter, entityId, negativeSeekMonths } = getFilterParams(state);
return JSON.stringify({ f: filter, st: stateFilter, e: entityId, s: negativeSeekMonths });
}
export function formatNegativeSeek(months) {
@@ -134,12 +136,13 @@ export function formatNegativeSeek(months) {
}
export function formatFilterLabel(filterKeyOrState) {
let f, st, s;
let f, st, e, s;
if (typeof filterKeyOrState === 'string') {
try {
const parsed = JSON.parse(filterKeyOrState);
f = parsed.f;
st = parsed.st;
e = parsed.e;
s = parsed.s;
} catch {
return filterKeyOrState;
@@ -148,17 +151,20 @@ export function formatFilterLabel(filterKeyOrState) {
if ('f' in filterKeyOrState) {
f = filterKeyOrState.f;
st = filterKeyOrState.st;
e = filterKeyOrState.e;
s = filterKeyOrState.s;
} else {
const params = getFilterParams(filterKeyOrState);
f = params.filter;
st = params.stateFilter;
e = params.entityId;
s = params.negativeSeekMonths;
}
}
const parts = [];
if (f) parts.push(`Suche: „${f}“`);
if (e != null) parts.push(`Kunde: #${e}`);
if (st) parts.push(`Status: ${st}`);
if (s < 0) parts.push(`Seek: ${formatNegativeSeek(s)}`);
if (parts.length === 0) return 'Standard (Alle Lieferscheine)';
@@ -436,6 +442,7 @@ function buildQueryParamsFor(filterParams, offset = 0, limit = CHUNK_SIZE) {
const trimmed = String(filterParams.filter ?? '').trim();
if (trimmed) params.search = trimmed;
if (filterParams.stateFilter) params.state = filterParams.stateFilter;
if (filterParams.entityId != null) params.entityId = filterParams.entityId;
if (filterParams.negativeSeekMonths != null && Number(filterParams.negativeSeekMonths) < 0) {
params.seekMonths = Number(filterParams.negativeSeekMonths);
}
@@ -784,6 +791,12 @@ const actions = {
onFilterChange(true);
},
setEntityId(entityId) {
const parsed = entityId != null && entityId !== '' ? Number(entityId) : null;
automat.setState({ entityId: parsed });
onFilterChange(true);
},
setSeekMonths(months) {
actions.addNegativeSeek(-months);
},

View File

@@ -29,6 +29,7 @@ import { backendErrorFrom } from '../../utils/backendError';
import { notifyError } from '../../utils/notify';
import { getMaxDocumentNumber } from '../../utils/documentNumber';
import BookDeliveryDialog from './BookDeliveryDialog';
import PartyFilterPanel, { PartyFilterButton, PartyFilterChip } from './PartyFilterPanel';
import withNavigate from '../withNavigate';
import {
formatDate,
@@ -442,7 +443,14 @@ class DeliverySearchField extends PureComponent {
}
render() {
const { inputRef, onCreate } = this.props;
const {
inputRef,
onCreate,
supplierFilterOpen,
onToggleSupplierFilter,
selectedSupplier,
onClearSupplierFilter,
} = this.props;
const { input, onlyUnbooked, seekMonths, seekYears } = this.state;
const monthActive = seekMonths > 0;
const monthLabel = !monthActive ? '-1 Month' : `-${seekMonths} Month${seekMonths > 1 ? 's' : ''}`;
@@ -461,22 +469,34 @@ class DeliverySearchField extends PureComponent {
pb: 2,
}}
>
{/* Top row: search field & create button */}
{/* Top row: search field, supplier filter button, active chip & create button */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
flexWrap: 'wrap',
}}
>
<SingleLineTextEdit
sx={{ flex: 1 }}
sx={{ flex: 1, minWidth: { xs: '100%', sm: 220 } }}
size="small"
search
clear
value={input}
onChange={(next) => deliveryAutomat.actions.filter(next)}
/>
<PartyFilterButton
title="Lieferanten"
open={supplierFilterOpen}
onClick={onToggleSupplierFilter}
/>
{selectedSupplier && (
<PartyFilterChip
label={partyDisplayName('delivery', selectedSupplier)}
onDelete={onClearSupplierFilter}
/>
)}
<Button variant="contained" onClick={onCreate}>
{cfg.createButton}
</Button>
@@ -635,7 +655,10 @@ export class DeliveryList extends PureComponent {
items: deliveryAutomat.getItems(),
unfilteredTotal: deliveryAutomat.state.unfilteredTotal ?? 0,
filterKey: deliveryAutomat.getFilterKey?.() ?? '',
supplierId: deliveryAutomat.state.supplierId ?? null,
supplierFilterOpen: false,
suppliersById: {},
suppliersList: [],
busyId: null,
bookingDoc: null,
bookingOpen: false,
@@ -650,6 +673,8 @@ export class DeliveryList extends PureComponent {
this.handleOpenBookDialog = this.handleOpenBookDialog.bind(this);
this.handleCloseBookDialog = this.handleCloseBookDialog.bind(this);
this.handleBookSuccess = this.handleBookSuccess.bind(this);
this.handleToggleSupplierFilter = this.handleToggleSupplierFilter.bind(this);
this.handleSelectSupplier = this.handleSelectSupplier.bind(this);
}
componentDidMount() {
@@ -658,12 +683,14 @@ export class DeliveryList extends PureComponent {
items: state.items,
unfilteredTotal: state.unfilteredTotal ?? 0,
filterKey: deliveryAutomat.getFilterKey(state),
supplierId: state.supplierId ?? null,
}));
if (this.state.items !== deliveryAutomat.state.items) {
this.setState({
items: deliveryAutomat.state.items,
unfilteredTotal: deliveryAutomat.state.unfilteredTotal ?? 0,
filterKey: deliveryAutomat.getFilterKey(deliveryAutomat.state),
supplierId: deliveryAutomat.state.supplierId ?? null,
});
}
this.unsubNumber = numberAutomat.subscribe(this, () => {
@@ -680,11 +707,22 @@ export class DeliveryList extends PureComponent {
this.unsubNumber?.();
}
handleToggleSupplierFilter() {
this.setState((prev) => ({ supplierFilterOpen: !prev.supplierFilterOpen }));
}
handleSelectSupplier(supplierId) {
deliveryAutomat.actions.setSupplierId(supplierId);
}
async loadSuppliers() {
try {
const parties = await loadParties('delivery');
if (!this._cancelled) {
this.setState({ suppliersById: cfg.partiesByIdFromList(parties) });
this.setState({
suppliersById: cfg.partiesByIdFromList(parties),
suppliersList: parties || [],
});
}
} catch {
// Non-fatal: names fall back to ids.
@@ -750,7 +788,18 @@ export class DeliveryList extends PureComponent {
}
render() {
const { items, unfilteredTotal, filterKey, suppliersById, busyId, bookingDoc, bookingOpen } = this.state;
const {
items,
unfilteredTotal,
filterKey,
suppliersById,
suppliersList,
supplierId,
supplierFilterOpen,
busyId,
bookingDoc,
bookingOpen,
} = this.state;
rowContext.suppliersById = suppliersById;
rowContext.busyId = busyId;
@@ -759,10 +808,31 @@ export class DeliveryList extends PureComponent {
rowContext.onOpenBookDialog = this.handleOpenBookDialog;
rowContext.headerItem = this.headerItem;
const supplierCounts = {};
if (Array.isArray(items)) {
for (const d of items) {
if (d?.supplierId) {
supplierCounts[d.supplierId] = (supplierCounts[d.supplierId] || 0) + 1;
}
}
}
const selectedSupplier = supplierId ? (suppliersById[supplierId] || { id: supplierId, name: `Lieferant #${supplierId}` }) : null;
const searchField = (
<DeliverySearchField
inputRef={this.searchRef}
onCreate={this.handleCreate}
supplierFilterOpen={supplierFilterOpen}
onToggleSupplierFilter={this.handleToggleSupplierFilter}
selectedSupplier={selectedSupplier}
onClearSupplierFilter={() => this.handleSelectSupplier(null)}
/>
);
if (items === null && deliveryAutomat.state.status === 'failed') {
return (
<>
<DeliverySearchField inputRef={this.searchRef} onCreate={this.handleCreate} />
{searchField}
<PanelTileGrid>
<PanelTile wide={3} minHeight={0} color={cfg.listColor} sx={{ p: 2 }}>
<BackendError error={backendErrorFrom(deliveryAutomat.state.error)} onRetry={this.handleRetry} />
@@ -778,64 +848,79 @@ export class DeliveryList extends PureComponent {
return (
<>
<DeliverySearchField inputRef={this.searchRef} onCreate={this.handleCreate} />
<PanelTileGrid>
<PanelTile wide={3} minHeight={0} color={cfg.listColor} sx={{ pb: 0, position: 'relative' }}>
<FillSpace
aboveRef={[this.searchRef]}
reserveBottom={32}
minPageSize={8}
minHeight={0}
resetKey={filterKey}
items={items || []}
totalCount={items ? items.length : 0}
unfilteredTotal={unfilteredTotal}
headerItem={this.headerItem}
renderItem={DeliveryRow}
virtualScrollbar
header={
<Box
sx={{
...rowGridSx,
height: 36,
minHeight: 36,
px: 2,
borderBottom: '1px solid',
borderColor: 'divider',
}}
>
{renderInvisibleDummyRow(this.headerItem)}
<Box sx={{ gridColumn: 1, gridRow: 2, ...headerCellSx }}>Nr. / Ref.</Box>
<Box sx={{ gridColumn: 2, gridRow: 2, ...headerCellSx }}>Lieferung</Box>
<Box sx={{ gridColumn: 3, gridRow: 2, ...headerCellSx }}>Lieferant</Box>
<Box sx={{ gridColumn: 4, gridRow: 2, ...headerCellSx }}>Lagerbuchung</Box>
<Box sx={{ gridColumn: 5, gridRow: 2, display: 'flex', justifyContent: 'center', textAlign: 'center', ...headerCellSx }}>Pos.</Box>
<Box sx={{ gridColumn: 6, gridRow: 2, ...headerCellSx }}>Anhänge</Box>
<Box sx={{ gridColumn: 7, gridRow: 2, ...headerCellSx }} />
</Box>
}
onPageChange={this.handlePageChange}
{searchField}
<Box sx={{ display: 'flex', gap: 2.5, alignItems: 'stretch' }}>
{supplierFilterOpen && (
<PartyFilterPanel
title="Lieferanten"
parties={suppliersList}
selectedId={supplierId}
onSelect={this.handleSelectSupplier}
onClose={this.handleToggleSupplierFilter}
counts={supplierCounts}
searchPlaceholder="Lieferant suchen…"
/>
{items === null && (
<Box
sx={{
position: 'absolute',
top: 52,
left: 0,
right: 0,
bottom: 32,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
pointerEvents: 'none',
zIndex: 2,
}}
>
<CircularProgress size={32} />
</Box>
)}
</PanelTile>
</PanelTileGrid>
)}
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
<PanelTileGrid>
<PanelTile wide={3} minHeight={0} color={cfg.listColor} sx={{ pb: 0, position: 'relative' }}>
<FillSpace
aboveRef={[this.searchRef]}
reserveBottom={32}
minPageSize={8}
minHeight={0}
resetKey={filterKey}
items={items || []}
totalCount={items ? items.length : 0}
unfilteredTotal={unfilteredTotal}
headerItem={this.headerItem}
renderItem={DeliveryRow}
virtualScrollbar
header={
<Box
sx={{
...rowGridSx,
height: 36,
minHeight: 36,
px: 2,
borderBottom: '1px solid',
borderColor: 'divider',
}}
>
{renderInvisibleDummyRow(this.headerItem)}
<Box sx={{ gridColumn: 1, gridRow: 2, ...headerCellSx }}>Nr. / Ref.</Box>
<Box sx={{ gridColumn: 2, gridRow: 2, ...headerCellSx }}>Lieferung</Box>
<Box sx={{ gridColumn: 3, gridRow: 2, ...headerCellSx }}>Lieferant</Box>
<Box sx={{ gridColumn: 4, gridRow: 2, ...headerCellSx }}>Lagerbuchung</Box>
<Box sx={{ gridColumn: 5, gridRow: 2, display: 'flex', justifyContent: 'center', textAlign: 'center', ...headerCellSx }}>Pos.</Box>
<Box sx={{ gridColumn: 6, gridRow: 2, ...headerCellSx }}>Anhänge</Box>
<Box sx={{ gridColumn: 7, gridRow: 2, ...headerCellSx }} />
</Box>
}
onPageChange={this.handlePageChange}
/>
{items === null && (
<Box
sx={{
position: 'absolute',
top: 52,
left: 0,
right: 0,
bottom: 32,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
pointerEvents: 'none',
zIndex: 2,
}}
>
<CircularProgress size={32} />
</Box>
)}
</PanelTile>
</PanelTileGrid>
</Box>
</Box>
<BookDeliveryDialog
open={bookingOpen}

View File

@@ -32,6 +32,7 @@ import { backendErrorFrom } from '../../utils/backendError';
import { notifyError } from '../../utils/notify';
import { formatPrice } from '../../utils/price';
import { getMaxDocumentNumber } from '../../utils/documentNumber';
import PartyFilterPanel, { PartyFilterButton, PartyFilterChip } from './PartyFilterPanel';
import withNavigate from '../withNavigate';
import {
formatDate,
@@ -333,7 +334,14 @@ class OrderSearchField extends PureComponent {
}
render() {
const { inputRef, onCreate } = this.props;
const {
inputRef,
onCreate,
customerFilterOpen,
onToggleCustomerFilter,
selectedCustomer,
onClearCustomerFilter,
} = this.props;
const { input, onlyUnpaid, onlyUndelivered, seekMonths, seekYears } = this.state;
const monthActive = seekMonths > 0;
const monthLabel = !monthActive ? '-1 Month' : `-${seekMonths} Month${seekMonths > 1 ? 's' : ''}`;
@@ -352,22 +360,34 @@ class OrderSearchField extends PureComponent {
pb: 2,
}}
>
{/* Top row: search field & create button */}
{/* Top row: search field, customer filter button, active chip & create button */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
flexWrap: 'wrap',
}}
>
<SingleLineTextEdit
sx={{ flex: 1 }}
sx={{ flex: 1, minWidth: { xs: '100%', sm: 220 } }}
size="small"
search
clear
value={input}
onChange={(next) => orderAutomat.actions.filter(next)}
/>
<PartyFilterButton
title="Kunden"
open={customerFilterOpen}
onClick={onToggleCustomerFilter}
/>
{selectedCustomer && (
<PartyFilterChip
label={partyDisplayName('order', selectedCustomer)}
onDelete={onClearCustomerFilter}
/>
)}
<Button variant="contained" onClick={onCreate}>
{cfg.createButton}
</Button>
@@ -548,7 +568,10 @@ export class OrderList extends PureComponent {
items: orderAutomat.getItems(),
unfilteredTotal: orderAutomat.state.unfilteredTotal ?? 0,
filterKey: orderAutomat.getFilterKey?.() ?? '',
entityId: orderAutomat.state.entityId ?? null,
customerFilterOpen: false,
partiesById: {},
partiesList: [],
busyId: null,
};
this.searchRef = createRef();
@@ -560,6 +583,8 @@ export class OrderList extends PureComponent {
this.handleTogglePaid = this.handleTogglePaid.bind(this);
this.handleToggleDelivered = this.handleToggleDelivered.bind(this);
this.handlePageChange = this.handlePageChange.bind(this);
this.handleToggleCustomerFilter = this.handleToggleCustomerFilter.bind(this);
this.handleSelectCustomer = this.handleSelectCustomer.bind(this);
}
componentDidMount() {
@@ -568,6 +593,7 @@ export class OrderList extends PureComponent {
items: state.items,
unfilteredTotal: state.unfilteredTotal ?? 0,
filterKey: orderAutomat.getFilterKey(state),
entityId: state.entityId ?? null,
}));
this.unsubNumber = numberAutomat.subscribe(this, () => {
this.headerItem = createDummyTestOrder();
@@ -583,11 +609,22 @@ export class OrderList extends PureComponent {
this.unsubNumber?.();
}
handleToggleCustomerFilter() {
this.setState((prev) => ({ customerFilterOpen: !prev.customerFilterOpen }));
}
handleSelectCustomer(entityId) {
orderAutomat.actions.setEntityId(entityId);
}
async loadParties() {
try {
const parties = await loadParties('order');
if (!this._cancelled) {
this.setState({ partiesById: cfg.partiesByIdFromList(parties) });
this.setState({
partiesById: cfg.partiesByIdFromList(parties),
partiesList: parties || [],
});
}
} catch {
// Non-fatal: names fall back to ids.
@@ -663,7 +700,16 @@ export class OrderList extends PureComponent {
}
render() {
const { items, unfilteredTotal, filterKey, partiesById, busyId } = this.state;
const {
items,
unfilteredTotal,
filterKey,
partiesById,
partiesList,
entityId,
customerFilterOpen,
busyId,
} = this.state;
rowContext.partiesById = partiesById;
rowContext.busyId = busyId;
@@ -673,10 +719,31 @@ export class OrderList extends PureComponent {
rowContext.onToggleDelivered = this.handleToggleDelivered;
rowContext.headerItem = this.headerItem;
const customerCounts = {};
if (Array.isArray(items)) {
for (const ord of items) {
if (ord?.entityId) {
customerCounts[ord.entityId] = (customerCounts[ord.entityId] || 0) + 1;
}
}
}
const selectedCustomer = entityId ? (partiesById[entityId] || { id: entityId, name: `Kunde #${entityId}` }) : null;
const searchField = (
<OrderSearchField
inputRef={this.searchRef}
onCreate={this.handleCreate}
customerFilterOpen={customerFilterOpen}
onToggleCustomerFilter={this.handleToggleCustomerFilter}
selectedCustomer={selectedCustomer}
onClearCustomerFilter={() => this.handleSelectCustomer(null)}
/>
);
if (items === null && orderAutomat.state.status === 'failed') {
return (
<>
<OrderSearchField inputRef={this.searchRef} onCreate={this.handleCreate} />
{searchField}
<PanelTileGrid>
<PanelTile wide={3} minHeight={0} color={cfg.listColor} sx={{ p: 2 }}>
<BackendError error={backendErrorFrom(orderAutomat.state.error)} onRetry={this.handleRetry} />
@@ -688,73 +755,88 @@ export class OrderList extends PureComponent {
return (
<>
<OrderSearchField inputRef={this.searchRef} onCreate={this.handleCreate} />
<PanelTileGrid>
<PanelTile wide={3} minHeight={0} color={cfg.listColor} sx={{ pb: 0, position: 'relative' }}>
<FillSpace
aboveRef={[this.searchRef]}
reserveBottom={32}
minPageSize={8}
minHeight={0}
resetKey={filterKey}
items={items || []}
totalCount={items ? items.length : 0}
unfilteredTotal={unfilteredTotal}
headerItem={this.headerItem}
renderItem={OrderRow}
virtualScrollbar
header={
<Box
sx={{
...rowGridSx,
height: 36,
minHeight: 36,
px: 2,
borderBottom: '1px solid',
borderColor: 'divider',
}}
>
{renderInvisibleDummyRow(this.headerItem)}
<Box sx={{ gridColumn: 1, gridRow: 2, ...headerCellSx }}>Nr. / Ref.</Box>
<Box sx={{ gridColumn: 2, gridRow: 2, ...headerCellSx }}>Datum</Box>
<Box sx={{ gridColumn: 3, gridRow: 2, ...headerCellSx }}>Kunde</Box>
<Box sx={{ gridColumn: 4, gridRow: 2, display: 'flex', justifyContent: 'center', alignItems: 'center', ...headerCellSx }}>
<Tooltip title="Bezahlt">
<PaymentsOutlinedIcon sx={{ fontSize: 18 }} />
</Tooltip>
</Box>
<Box sx={{ gridColumn: 5, gridRow: 2, display: 'flex', justifyContent: 'center', alignItems: 'center', ...headerCellSx }}>
<Tooltip title="Geliefert">
<LocalShippingOutlinedIcon sx={{ fontSize: 18 }} />
</Tooltip>
</Box>
<Box sx={{ gridColumn: 6, gridRow: 2, display: 'flex', justifyContent: 'center', textAlign: 'center', ...headerCellSx }}>Pos.</Box>
<Box sx={{ gridColumn: 7, gridRow: 2, textAlign: 'right', ...headerCellSx }}>Gesamt</Box>
<Box sx={{ gridColumn: 8, gridRow: 2, ...headerCellSx }} />
</Box>
}
onPageChange={this.handlePageChange}
{searchField}
<Box sx={{ display: 'flex', gap: 2.5, alignItems: 'stretch' }}>
{customerFilterOpen && (
<PartyFilterPanel
title="Kunden"
parties={partiesList}
selectedId={entityId}
onSelect={this.handleSelectCustomer}
onClose={this.handleToggleCustomerFilter}
counts={customerCounts}
searchPlaceholder="Kunde suchen…"
/>
{items === null && (
<Box
sx={{
position: 'absolute',
top: 52,
left: 0,
right: 0,
bottom: 32,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
pointerEvents: 'none',
zIndex: 2,
}}
>
<CircularProgress size={32} />
</Box>
)}
</PanelTile>
</PanelTileGrid>
)}
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
<PanelTileGrid>
<PanelTile wide={3} minHeight={0} color={cfg.listColor} sx={{ pb: 0, position: 'relative' }}>
<FillSpace
aboveRef={[this.searchRef]}
reserveBottom={32}
minPageSize={8}
minHeight={0}
resetKey={filterKey}
items={items || []}
totalCount={items ? items.length : 0}
unfilteredTotal={unfilteredTotal}
headerItem={this.headerItem}
renderItem={OrderRow}
virtualScrollbar
header={
<Box
sx={{
...rowGridSx,
height: 36,
minHeight: 36,
px: 2,
borderBottom: '1px solid',
borderColor: 'divider',
}}
>
{renderInvisibleDummyRow(this.headerItem)}
<Box sx={{ gridColumn: 1, gridRow: 2, ...headerCellSx }}>Nr. / Ref.</Box>
<Box sx={{ gridColumn: 2, gridRow: 2, ...headerCellSx }}>Datum</Box>
<Box sx={{ gridColumn: 3, gridRow: 2, ...headerCellSx }}>Kunde</Box>
<Box sx={{ gridColumn: 4, gridRow: 2, display: 'flex', justifyContent: 'center', alignItems: 'center', ...headerCellSx }}>
<Tooltip title="Bezahlt">
<PaymentsOutlinedIcon sx={{ fontSize: 18 }} />
</Tooltip>
</Box>
<Box sx={{ gridColumn: 5, gridRow: 2, display: 'flex', justifyContent: 'center', alignItems: 'center', ...headerCellSx }}>
<Tooltip title="Geliefert">
<LocalShippingOutlinedIcon sx={{ fontSize: 18 }} />
</Tooltip>
</Box>
<Box sx={{ gridColumn: 6, gridRow: 2, display: 'flex', justifyContent: 'center', textAlign: 'center', ...headerCellSx }}>Pos.</Box>
<Box sx={{ gridColumn: 7, gridRow: 2, textAlign: 'right', ...headerCellSx }}>Gesamt</Box>
<Box sx={{ gridColumn: 8, gridRow: 2, ...headerCellSx }} />
</Box>
}
onPageChange={this.handlePageChange}
/>
{items === null && (
<Box
sx={{
position: 'absolute',
top: 52,
left: 0,
right: 0,
bottom: 32,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
pointerEvents: 'none',
zIndex: 2,
}}
>
<CircularProgress size={32} />
</Box>
)}
</PanelTile>
</PanelTileGrid>
</Box>
</Box>
</>
);
}

View File

@@ -0,0 +1,306 @@
import { PureComponent } from 'react';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Chip from '@mui/material/Chip';
import IconButton from '@mui/material/IconButton';
import InputAdornment from '@mui/material/InputAdornment';
import List from '@mui/material/List';
import ListItemButton from '@mui/material/ListItemButton';
import ListItemText from '@mui/material/ListItemText';
import Paper from '@mui/material/Paper';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
import ClearIcon from '@mui/icons-material/Clear';
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import FilterListIcon from '@mui/icons-material/FilterList';
import SearchIcon from '@mui/icons-material/Search';
import { entityDisplayName } from './manifestConfig';
/**
* Filter toggle button matching the /artikel dropdown style.
*/
export class PartyFilterButton extends PureComponent {
render() {
const {
title,
open,
onClick,
disabled = false,
sx,
} = this.props;
return (
<Button
variant={open ? 'contained' : 'outlined'}
color="inherit"
disabled={disabled}
onClick={onClick}
startIcon={<FilterListIcon />}
endIcon={open ? <ExpandLessIcon /> : <ExpandMoreIcon />}
sx={{
height: 36,
whiteSpace: 'nowrap',
flexShrink: 0,
textTransform: 'none',
fontWeight: open ? 600 : 500,
...sx,
}}
>
{title}
</Button>
);
}
}
/**
* Active filter chip displayed next to the search/filter controls.
*/
export class PartyFilterChip extends PureComponent {
render() {
const { label, onDelete, sx } = this.props;
if (!label) return null;
return (
<Chip
label={label}
color="primary"
variant="outlined"
onDelete={onDelete}
sx={{
height: 36,
borderRadius: 1.5,
fontWeight: 500,
flexShrink: 0,
...sx,
}}
/>
);
}
}
/**
* Side-panel list of parties (suppliers / customers) with live search and count badges.
*/
export default class PartyFilterPanel extends PureComponent {
constructor(props) {
super(props);
this.state = {
search: '',
};
}
handleSearchChange = (e) => {
this.setState({ search: e.target.value });
};
handleClearSearch = () => {
this.setState({ search: '' });
};
getPartyName(party) {
if (!party) return '';
if (typeof party === 'string') return party;
if (party.firstName || party.customergroupId) {
return entityDisplayName(party);
}
return party.name || party.firstName || `ID #${party.id}`;
}
render() {
const {
title,
parties = [],
selectedId,
onSelect,
onClose,
counts = {},
searchPlaceholder,
width = 250,
} = this.props;
const { search } = this.state;
const term = search.trim().toLowerCase();
// Filter by internal search
const filtered = parties.filter((p) => {
if (!term) return true;
const name = this.getPartyName(p).toLowerCase();
const number = String(p.number ?? '').toLowerCase();
const city = String(p.city ?? '').toLowerCase();
return name.includes(term) || number.includes(term) || city.includes(term);
});
// Sort: selected first, then by count descending, then alphabetically
const sorted = [...filtered].sort((a, b) => {
const aSelected = String(a.id) === String(selectedId);
const bSelected = String(b.id) === String(selectedId);
if (aSelected && !bSelected) return -1;
if (!aSelected && bSelected) return 1;
const aCount = counts[a.id] || 0;
const bCount = counts[b.id] || 0;
if (bCount !== aCount) return bCount - aCount;
const aName = this.getPartyName(a);
const bName = this.getPartyName(b);
return aName.localeCompare(bName, 'de');
});
return (
<Box
sx={{
width,
flexShrink: 0,
position: 'relative',
}}
>
<Paper
variant="outlined"
sx={{
position: 'absolute',
inset: 0,
boxSizing: 'border-box',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
borderRadius: 3,
bgcolor: 'background.paper',
boxShadow: 1,
}}
>
{/* Header */}
<Box
sx={{
px: 2,
py: 1.25,
bgcolor: 'action.hover',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
borderBottom: '1px solid',
borderColor: 'divider',
flexShrink: 0,
}}
>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
{title}
</Typography>
<IconButton
size="small"
onClick={onClose}
title={`${title} einklappen`}
aria-label={`${title} einklappen`}
>
<ChevronLeftIcon fontSize="small" />
</IconButton>
</Box>
{/* Quick filter input */}
<Box sx={{ p: 1, borderBottom: '1px solid', borderColor: 'divider', flexShrink: 0 }}>
<TextField
size="small"
fullWidth
autoComplete="off"
placeholder={searchPlaceholder || `${title} suchen…`}
value={search}
onChange={this.handleSearchChange}
slotProps={{
input: {
sx: { height: 32, fontSize: '0.8125rem' },
startAdornment: (
<InputAdornment position="start">
<SearchIcon sx={{ fontSize: 18 }} color="action" />
</InputAdornment>
),
endAdornment: search ? (
<InputAdornment position="end">
<IconButton
size="small"
onClick={this.handleClearSearch}
onMouseDown={(e) => e.preventDefault()}
>
<ClearIcon sx={{ fontSize: 16 }} />
</IconButton>
</InputAdornment>
) : null,
},
}}
/>
</Box>
{/* Parties list */}
<List dense disablePadding sx={{ flexGrow: 1, overflowY: 'auto' }}>
{sorted.length === 0 ? (
<Box sx={{ p: 2, textAlign: 'center' }}>
<Typography variant="body2" color="text.secondary">
Keine Treffer
</Typography>
</Box>
) : (
sorted.map((party) => {
const isSelected = String(party.id) === String(selectedId);
const name = this.getPartyName(party);
const count = counts[party.id] || 0;
const label = count > 0 ? `${name} (${count})` : name;
const details = party.number ? `Nr. ${party.number}` : null;
return (
<ListItemButton
key={party.id}
selected={isSelected}
onMouseDown={(e) => e.preventDefault()}
onClick={() => onSelect?.(isSelected ? null : party.id)}
sx={{
px: 2,
py: 0.75,
'&.Mui-selected': {
bgcolor: 'primary.light',
color: 'primary.contrastText',
'&:hover': {
bgcolor: 'primary.main',
},
},
}}
>
<ListItemText
primary={label}
secondary={details}
slotProps={{
primary: {
variant: 'body2',
noWrap: true,
sx: { fontWeight: isSelected ? 600 : 400 },
},
secondary: {
variant: 'caption',
noWrap: true,
sx: {
color: isSelected ? 'inherit' : 'text.secondary',
opacity: isSelected ? 0.8 : 1,
},
},
}}
/>
{isSelected && (
<ClearIcon
fontSize="small"
sx={{
ml: 1,
flexShrink: 0,
fontSize: 16,
color: isSelected ? 'inherit' : 'action.active',
}}
/>
)}
</ListItemButton>
);
})
)}
</List>
</Paper>
</Box>
);
}
}

View File

@@ -24,6 +24,7 @@ import { backendErrorFrom } from '../../utils/backendError';
import { notifyError } from '../../utils/notify';
import { formatPrice } from '../../utils/price';
import { getMaxDocumentNumber } from '../../utils/documentNumber';
import PartyFilterPanel, { PartyFilterButton, PartyFilterChip } from './PartyFilterPanel';
import withNavigate from '../withNavigate';
import {
formatDate,
@@ -385,7 +386,14 @@ class QuoteSearchField extends PureComponent {
}
render() {
const { inputRef, onCreate } = this.props;
const {
inputRef,
onCreate,
customerFilterOpen,
onToggleCustomerFilter,
selectedCustomer,
onClearCustomerFilter,
} = this.props;
const { input, seekMonths, seekYears } = this.state;
const monthActive = seekMonths > 0;
const monthLabel = !monthActive ? '-1 Month' : `-${seekMonths} Month${seekMonths > 1 ? 's' : ''}`;
@@ -420,6 +428,17 @@ class QuoteSearchField extends PureComponent {
value={input}
onChange={(next) => quoteAutomat.actions.filter(next)}
/>
<PartyFilterButton
title="Kunden"
open={customerFilterOpen}
onClick={onToggleCustomerFilter}
/>
{selectedCustomer && (
<PartyFilterChip
label={partyDisplayName('quote', selectedCustomer)}
onDelete={onClearCustomerFilter}
/>
)}
<Button variant="contained" onClick={onCreate}>
{cfg.createButton}
</Button>
@@ -549,7 +568,10 @@ export class QuoteList extends PureComponent {
items: quoteAutomat.getItems(),
unfilteredTotal: quoteAutomat.state.unfilteredTotal ?? 0,
filterKey: quoteAutomat.getFilterKey?.() ?? '',
entityId: quoteAutomat.state.entityId ?? null,
customerFilterOpen: false,
partiesById: {},
partiesList: [],
busyId: null,
};
this.searchRef = createRef();
@@ -559,6 +581,8 @@ export class QuoteList extends PureComponent {
this.handleOpen = this.handleOpen.bind(this);
this.handleDelete = this.handleDelete.bind(this);
this.handlePageChange = this.handlePageChange.bind(this);
this.handleToggleCustomerFilter = this.handleToggleCustomerFilter.bind(this);
this.handleSelectCustomer = this.handleSelectCustomer.bind(this);
}
componentDidMount() {
@@ -567,6 +591,7 @@ export class QuoteList extends PureComponent {
items: state.items,
unfilteredTotal: state.unfilteredTotal ?? 0,
filterKey: quoteAutomat.getFilterKey(state),
entityId: state.entityId ?? null,
}));
this.unsubNumber = numberAutomat.subscribe(this, () => {
this.headerItem = createDummyTestQuote();
@@ -582,11 +607,22 @@ export class QuoteList extends PureComponent {
this.unsubNumber?.();
}
handleToggleCustomerFilter() {
this.setState((prev) => ({ customerFilterOpen: !prev.customerFilterOpen }));
}
handleSelectCustomer(entityId) {
quoteAutomat.actions.setEntityId(entityId);
}
async loadParties() {
try {
const parties = await loadParties('quote');
if (!this._cancelled) {
this.setState({ partiesById: cfg.partiesByIdFromList(parties) });
this.setState({
partiesById: cfg.partiesByIdFromList(parties),
partiesList: parties || [],
});
}
} catch {
// Non-fatal: names fall back to ids or customerName.
@@ -642,7 +678,16 @@ export class QuoteList extends PureComponent {
}
render() {
const { items, unfilteredTotal, filterKey, partiesById, busyId } = this.state;
const {
items,
unfilteredTotal,
filterKey,
entityId,
customerFilterOpen,
partiesById,
partiesList,
busyId,
} = this.state;
rowContext.partiesById = partiesById;
rowContext.busyId = busyId;
@@ -650,10 +695,31 @@ export class QuoteList extends PureComponent {
rowContext.onDelete = this.handleDelete;
rowContext.headerItem = this.headerItem;
const customerCounts = {};
if (Array.isArray(items)) {
for (const q of items) {
if (q?.entityId) {
customerCounts[q.entityId] = (customerCounts[q.entityId] || 0) + 1;
}
}
}
const selectedCustomer = entityId ? (partiesById[entityId] || { id: entityId, name: `Kunde #${entityId}` }) : null;
const searchField = (
<QuoteSearchField
inputRef={this.searchRef}
onCreate={this.handleCreate}
customerFilterOpen={customerFilterOpen}
onToggleCustomerFilter={this.handleToggleCustomerFilter}
selectedCustomer={selectedCustomer}
onClearCustomerFilter={() => this.handleSelectCustomer(null)}
/>
);
if (items === null && quoteAutomat.state.status === 'failed') {
return (
<>
<QuoteSearchField inputRef={this.searchRef} onCreate={this.handleCreate} />
{searchField}
<PanelTileGrid>
<PanelTile wide={3} minHeight={0} color={cfg.listColor} sx={{ p: 2 }}>
<BackendError error={backendErrorFrom(quoteAutomat.state.error)} onRetry={this.handleRetry} />
@@ -665,64 +731,79 @@ export class QuoteList extends PureComponent {
return (
<>
<QuoteSearchField inputRef={this.searchRef} onCreate={this.handleCreate} />
<PanelTileGrid>
<PanelTile wide={3} minHeight={0} color={cfg.listColor} sx={{ pb: 0, position: 'relative' }}>
<FillSpace
aboveRef={[this.searchRef]}
reserveBottom={32}
minPageSize={8}
minHeight={0}
resetKey={filterKey}
items={items || []}
totalCount={items ? items.length : 0}
unfilteredTotal={unfilteredTotal}
headerItem={this.headerItem}
renderItem={QuoteRow}
virtualScrollbar
header={
<Box
sx={{
...rowGridSx,
height: 36,
minHeight: 36,
px: 2,
borderBottom: '1px solid',
borderColor: 'divider',
}}
>
{renderInvisibleDummyRow(this.headerItem)}
<Box sx={{ gridColumn: 1, gridRow: 2, ...headerCellSx }}>Nr.</Box>
<Box sx={{ gridColumn: 2, gridRow: 2, ...headerCellSx }}>Datum</Box>
<Box sx={{ gridColumn: 3, gridRow: 2, ...headerCellSx }}>Kunde</Box>
<Box sx={{ gridColumn: 4, gridRow: 2, display: 'flex', justifyContent: 'center', textAlign: 'center', ...headerCellSx }}>Pos.</Box>
<Box sx={{ gridColumn: 5, gridRow: 2, textAlign: 'left', minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', ...headerCellSx }}></Box>
<Box sx={{ gridColumn: 6, gridRow: 2, textAlign: 'right', ...headerCellSx }}>Gesamt</Box>
<Box sx={{ gridColumn: 7, gridRow: 2, ...headerCellSx }} />
</Box>
}
onPageChange={this.handlePageChange}
{searchField}
<Box sx={{ display: 'flex', gap: 2.5, alignItems: 'stretch' }}>
{customerFilterOpen && (
<PartyFilterPanel
title="Kunden"
parties={partiesList}
selectedId={entityId}
onSelect={this.handleSelectCustomer}
onClose={this.handleToggleCustomerFilter}
counts={customerCounts}
searchPlaceholder="Kunde suchen…"
/>
{items === null && (
<Box
sx={{
position: 'absolute',
top: 52,
left: 0,
right: 0,
bottom: 32,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
pointerEvents: 'none',
zIndex: 2,
}}
>
<CircularProgress size={32} />
</Box>
)}
</PanelTile>
</PanelTileGrid>
)}
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
<PanelTileGrid>
<PanelTile wide={3} minHeight={0} color={cfg.listColor} sx={{ pb: 0, position: 'relative' }}>
<FillSpace
aboveRef={[this.searchRef]}
reserveBottom={32}
minPageSize={8}
minHeight={0}
resetKey={filterKey}
items={items || []}
totalCount={items ? items.length : 0}
unfilteredTotal={unfilteredTotal}
headerItem={this.headerItem}
renderItem={QuoteRow}
virtualScrollbar
header={
<Box
sx={{
...rowGridSx,
height: 36,
minHeight: 36,
px: 2,
borderBottom: '1px solid',
borderColor: 'divider',
}}
>
{renderInvisibleDummyRow(this.headerItem)}
<Box sx={{ gridColumn: 1, gridRow: 2, ...headerCellSx }}>Nr.</Box>
<Box sx={{ gridColumn: 2, gridRow: 2, ...headerCellSx }}>Datum</Box>
<Box sx={{ gridColumn: 3, gridRow: 2, ...headerCellSx }}>Kunde</Box>
<Box sx={{ gridColumn: 4, gridRow: 2, display: 'flex', justifyContent: 'center', textAlign: 'center', ...headerCellSx }}>Pos.</Box>
<Box sx={{ gridColumn: 5, gridRow: 2, textAlign: 'left', minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', ...headerCellSx }}></Box>
<Box sx={{ gridColumn: 6, gridRow: 2, textAlign: 'right', ...headerCellSx }}>Gesamt</Box>
<Box sx={{ gridColumn: 7, gridRow: 2, ...headerCellSx }} />
</Box>
}
onPageChange={this.handlePageChange}
/>
{items === null && (
<Box
sx={{
position: 'absolute',
top: 52,
left: 0,
right: 0,
bottom: 32,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
pointerEvents: 'none',
zIndex: 2,
}}
>
<CircularProgress size={32} />
</Box>
)}
</PanelTile>
</PanelTileGrid>
</Box>
</Box>
</>
);
}

View File

@@ -29,6 +29,7 @@ import { shipment as shipmentApi } from '../../api';
import { backendErrorFrom } from '../../utils/backendError';
import { notifyError } from '../../utils/notify';
import { getMaxDocumentNumber } from '../../utils/documentNumber';
import PartyFilterPanel, { PartyFilterButton, PartyFilterChip } from './PartyFilterPanel';
import withNavigate from '../withNavigate';
import ShipmentDetailDialog from './ShipmentDetailDialog';
import { getNextShipmentNumber } from './OrderShipmentDialog';
@@ -661,7 +662,14 @@ class ShipmentSearchField extends PureComponent {
}
render() {
const { inputRef, onCreate } = this.props;
const {
inputRef,
onCreate,
customerFilterOpen,
onToggleCustomerFilter,
selectedCustomer,
onClearCustomerFilter,
} = this.props;
const { input, stateFilter, seekMonths, seekYears } = this.state;
const monthActive = seekMonths > 0;
const monthLabel = !monthActive ? '-1 Monat' : `-${seekMonths} Monat${seekMonths > 1 ? 'e' : ''}`;
@@ -697,6 +705,17 @@ class ShipmentSearchField extends PureComponent {
value={input}
onChange={(next) => shipmentAutomat.actions.setFilter(next)}
/>
<PartyFilterButton
title="Kunden"
open={customerFilterOpen}
onClick={onToggleCustomerFilter}
/>
{selectedCustomer && (
<PartyFilterChip
label={partyDisplayName('order', selectedCustomer)}
onDelete={onClearCustomerFilter}
/>
)}
<Button variant="contained" onClick={onCreate}>
Lieferschein erstellen
</Button>
@@ -854,8 +873,11 @@ export class ShipmentList extends PureComponent {
total: shipmentAutomat.state.total ?? 0,
unfilteredTotal: shipmentAutomat.state.unfilteredTotal ?? 0,
filterKey: shipmentAutomat.getFilterKey?.() ?? '',
entityId: shipmentAutomat.state.entityId ?? null,
customerFilterOpen: false,
orders: orderAutomat.state?.items || [],
partiesById: {},
partiesList: [],
selectedShipment: null,
newShipmentModalOpen: false,
busyId: null,
@@ -867,6 +889,8 @@ export class ShipmentList extends PureComponent {
this.handleCloseShipment = this.handleCloseShipment.bind(this);
this.handleDeleteShipment = this.handleDeleteShipment.bind(this);
this.handlePageChange = this.handlePageChange.bind(this);
this.handleToggleCustomerFilter = this.handleToggleCustomerFilter.bind(this);
this.handleSelectCustomer = this.handleSelectCustomer.bind(this);
}
componentDidMount() {
@@ -876,6 +900,7 @@ export class ShipmentList extends PureComponent {
total: state.total ?? 0,
unfilteredTotal: state.unfilteredTotal ?? 0,
filterKey: shipmentAutomat.getFilterKey(state),
entityId: state.entityId ?? null,
}));
this.unsubOrder = orderAutomat.subscribe(this, (state) => ({
@@ -919,11 +944,22 @@ export class ShipmentList extends PureComponent {
this.unsubNumber?.();
}
handleToggleCustomerFilter() {
this.setState((prev) => ({ customerFilterOpen: !prev.customerFilterOpen }));
}
handleSelectCustomer(entityId) {
shipmentAutomat.actions.setEntityId(entityId);
}
async loadParties() {
try {
const parties = await loadParties('order');
if (!this._cancelled) {
this.setState({ partiesById: cfg.partiesByIdFromList(parties) });
this.setState({
partiesById: cfg.partiesByIdFromList(parties),
partiesList: parties || [],
});
}
} catch {
// Non-fatal
@@ -975,8 +1011,11 @@ export class ShipmentList extends PureComponent {
total,
unfilteredTotal,
filterKey,
entityId,
customerFilterOpen,
orders,
partiesById,
partiesList,
selectedShipment,
newShipmentModalOpen,
busyId,
@@ -988,10 +1027,31 @@ export class ShipmentList extends PureComponent {
rowContext.onDelete = this.handleDeleteShipment;
rowContext.headerItem = this.headerItem;
const customerCounts = {};
if (Array.isArray(items)) {
for (const s of items) {
if (s?.entityId) {
customerCounts[s.entityId] = (customerCounts[s.entityId] || 0) + 1;
}
}
}
const selectedCustomer = entityId ? (partiesById[entityId] || { id: entityId, name: `Kunde #${entityId}` }) : null;
const searchField = (
<ShipmentSearchField
inputRef={this.searchRef}
onCreate={() => this.setState({ newShipmentModalOpen: true })}
customerFilterOpen={customerFilterOpen}
onToggleCustomerFilter={this.handleToggleCustomerFilter}
selectedCustomer={selectedCustomer}
onClearCustomerFilter={() => this.handleSelectCustomer(null)}
/>
);
if (items === null && shipmentAutomat.state.status === 'failed') {
return (
<>
<ShipmentSearchField inputRef={this.searchRef} onCreate={() => this.setState({ newShipmentModalOpen: true })} />
{searchField}
<PanelTileGrid>
<PanelTile wide={3} minHeight={0} color={GREEN_PANEL} sx={{ p: 2 }}>
<BackendError error={backendErrorFrom(shipmentAutomat.state.error)} onRetry={this.handleRetry} />
@@ -1003,69 +1063,80 @@ export class ShipmentList extends PureComponent {
return (
<>
<ShipmentSearchField
inputRef={this.searchRef}
onCreate={() => this.setState({ newShipmentModalOpen: true })}
/>
<PanelTileGrid>
<PanelTile wide={3} minHeight={0} color={GREEN_PANEL} sx={{ pb: 0, position: 'relative' }}>
<FillSpace
aboveRef={[this.searchRef]}
reserveBottom={32}
minPageSize={8}
minHeight={0}
resetKey={filterKey}
items={items || []}
totalCount={items ? items.length : 0}
unfilteredTotal={unfilteredTotal}
headerItem={this.headerItem}
renderItem={ShipmentRow}
virtualScrollbar
header={
<Box
sx={{
...rowGridSx,
height: 36,
minHeight: 36,
px: 2,
borderBottom: '1px solid',
borderColor: 'divider',
}}
>
{renderInvisibleDummyRow(this.headerItem)}
<Box sx={{ gridColumn: 1, gridRow: 2, ...headerCellSx }}>Lieferschein / Ref</Box>
<Box sx={{ gridColumn: 2, gridRow: 2, ...headerCellSx }}>Datum</Box>
<Box sx={{ gridColumn: 3, gridRow: 2, ...headerCellSx }}>Auftrag</Box>
<Box sx={{ gridColumn: 4, gridRow: 2, ...headerCellSx }}>Kunde</Box>
<Box sx={{ gridColumn: 5, gridRow: 2, display: 'flex', justifyContent: 'center', textAlign: 'center', ...headerCellSx }}>Pos.</Box>
<Box sx={{ gridColumn: 6, gridRow: 2, ...headerCellSx }}>Paket / Tracking</Box>
<Box sx={{ gridColumn: 7, gridRow: 2, display: 'flex', justifyContent: 'center', textAlign: 'center', ...headerCellSx }}>Status</Box>
<Box sx={{ gridColumn: 8, gridRow: 2, ...headerCellSx }} />
</Box>
}
onPageChange={this.handlePageChange}
{searchField}
<Box sx={{ display: 'flex', gap: 2.5, alignItems: 'stretch' }}>
{customerFilterOpen && (
<PartyFilterPanel
title="Kunden"
parties={partiesList}
selectedId={entityId}
onSelect={this.handleSelectCustomer}
onClose={this.handleToggleCustomerFilter}
counts={customerCounts}
searchPlaceholder="Kunde suchen…"
/>
{items === null && (
<Box
sx={{
position: 'absolute',
top: 52,
left: 0,
right: 0,
bottom: 32,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
pointerEvents: 'none',
zIndex: 2,
}}
>
<CircularProgress size={32} />
</Box>
)}
</PanelTile>
</PanelTileGrid>
)}
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
<PanelTileGrid>
<PanelTile wide={3} minHeight={0} color={GREEN_PANEL} sx={{ pb: 0, position: 'relative' }}>
<FillSpace
aboveRef={[this.searchRef]}
reserveBottom={32}
minPageSize={8}
minHeight={0}
resetKey={filterKey}
items={items || []}
totalCount={items ? items.length : 0}
unfilteredTotal={unfilteredTotal}
headerItem={this.headerItem}
renderItem={ShipmentRow}
virtualScrollbar
header={
<Box
sx={{
...rowGridSx,
height: 36,
minHeight: 36,
px: 2,
borderBottom: '1px solid',
borderColor: 'divider',
}}
>
{renderInvisibleDummyRow(this.headerItem)}
<Box sx={{ gridColumn: 1, gridRow: 2, ...headerCellSx }}>Lieferschein / Ref</Box>
<Box sx={{ gridColumn: 2, gridRow: 2, ...headerCellSx }}>Datum</Box>
<Box sx={{ gridColumn: 3, gridRow: 2, ...headerCellSx }}>Auftrag</Box>
<Box sx={{ gridColumn: 4, gridRow: 2, ...headerCellSx }}>Kunde</Box>
<Box sx={{ gridColumn: 5, gridRow: 2, display: 'flex', justifyContent: 'center', textAlign: 'center', ...headerCellSx }}>Pos.</Box>
<Box sx={{ gridColumn: 6, gridRow: 2, ...headerCellSx }}>Paket / Tracking</Box>
<Box sx={{ gridColumn: 7, gridRow: 2, display: 'flex', justifyContent: 'center', textAlign: 'center', ...headerCellSx }}>Status</Box>
<Box sx={{ gridColumn: 8, gridRow: 2, ...headerCellSx }} />
</Box>
}
onPageChange={this.handlePageChange}
/>
{items === null && (
<Box
sx={{
position: 'absolute',
top: 52,
left: 0,
right: 0,
bottom: 32,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
pointerEvents: 'none',
zIndex: 2,
}}
>
<CircularProgress size={32} />
</Box>
)}
</PanelTile>
</PanelTileGrid>
</Box>
</Box>
{/* Modal: View / Manage Shipment Details */}
{selectedShipment && (

View File

@@ -18,6 +18,7 @@ export { default as BookDeliveryDialog } from './BookDeliveryDialog';
export { default as OrderShipmentDialog } from './OrderShipmentDialog';
export { default as ShipmentDetailDialog } from './ShipmentDetailDialog';
export { default as OrderDocumentsTiles } from './OrderDocumentsTiles';
export { default as PartyFilterPanel, PartyFilterButton, PartyFilterChip } from './PartyFilterPanel';
export { withManifestId } from './withManifestId';
export {
manifestConfig,