From 883c856bdb2a0a0d325c00c1b02a24d908dec9e6 Mon Sep 17 00:00:00 2001 From: seb Date: Mon, 19 Oct 2026 13:24:39 +0200 Subject: [PATCH] u --- src/api/order.js | 3 +- src/api/quote.js | 3 +- src/api/shipment.js | 1 + src/automats/orderAutomat.js | 21 +- src/automats/quoteAutomat.js | 21 +- src/automats/shipmentAutomat.js | 21 +- src/components/manifest/DeliveryList.jsx | 211 +++++++++---- src/components/manifest/OrderList.jsx | 226 +++++++++----- src/components/manifest/PartyFilterPanel.jsx | 306 +++++++++++++++++++ src/components/manifest/QuoteList.jsx | 203 ++++++++---- src/components/manifest/ShipmentList.jsx | 201 ++++++++---- src/components/manifest/index.js | 1 + 12 files changed, 943 insertions(+), 275 deletions(-) create mode 100644 src/components/manifest/PartyFilterPanel.jsx diff --git a/src/api/order.js b/src/api/order.js index 47f1853..299ed10 100644 --- a/src/api/order.js +++ b/src/api/order.js @@ -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)))); } diff --git a/src/api/quote.js b/src/api/quote.js index 27aae47..55af517 100644 --- a/src/api/quote.js +++ b/src/api/quote.js @@ -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)))); } diff --git a/src/api/shipment.js b/src/api/shipment.js index c02c50c..bdd3337 100644 --- a/src/api/shipment.js +++ b/src/api/shipment.js @@ -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}` : ''}`); diff --git a/src/automats/orderAutomat.js b/src/automats/orderAutomat.js index 068139f..0b9947b 100644 --- a/src/automats/orderAutomat.js +++ b/src/automats/orderAutomat.js @@ -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; diff --git a/src/automats/quoteAutomat.js b/src/automats/quoteAutomat.js index 9bff67b..dc372ff 100644 --- a/src/automats/quoteAutomat.js +++ b/src/automats/quoteAutomat.js @@ -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; diff --git a/src/automats/shipmentAutomat.js b/src/automats/shipmentAutomat.js index a56ddc8..a3927c3 100644 --- a/src/automats/shipmentAutomat.js +++ b/src/automats/shipmentAutomat.js @@ -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); }, diff --git a/src/components/manifest/DeliveryList.jsx b/src/components/manifest/DeliveryList.jsx index 3c1d272..da63c59 100644 --- a/src/components/manifest/DeliveryList.jsx +++ b/src/components/manifest/DeliveryList.jsx @@ -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 */} deliveryAutomat.actions.filter(next)} /> + + {selectedSupplier && ( + + )} @@ -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 = ( + this.handleSelectSupplier(null)} + /> + ); + if (items === null && deliveryAutomat.state.status === 'failed') { return ( <> - + {searchField} @@ -778,64 +848,79 @@ export class DeliveryList extends PureComponent { return ( <> - - - - - {renderInvisibleDummyRow(this.headerItem)} - Nr. / Ref. - Lieferung - Lieferant - Lagerbuchung - Pos. - Anhänge - - - } - onPageChange={this.handlePageChange} + {searchField} + + {supplierFilterOpen && ( + - {items === null && ( - - - - )} - - + )} + + + + + {renderInvisibleDummyRow(this.headerItem)} + Nr. / Ref. + Lieferung + Lieferant + Lagerbuchung + Pos. + Anhänge + + + } + onPageChange={this.handlePageChange} + /> + {items === null && ( + + + + )} + + + + 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 */} orderAutomat.actions.filter(next)} /> + + {selectedCustomer && ( + + )} @@ -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 = ( + this.handleSelectCustomer(null)} + /> + ); + if (items === null && orderAutomat.state.status === 'failed') { return ( <> - + {searchField} @@ -688,73 +755,88 @@ export class OrderList extends PureComponent { return ( <> - - - - - {renderInvisibleDummyRow(this.headerItem)} - Nr. / Ref. - Datum - Kunde - - - - - - - - - - - Pos. - Gesamt - - - } - onPageChange={this.handlePageChange} + {searchField} + + {customerFilterOpen && ( + - {items === null && ( - - - - )} - - + )} + + + + + {renderInvisibleDummyRow(this.headerItem)} + Nr. / Ref. + Datum + Kunde + + + + + + + + + + + Pos. + Gesamt + + + } + onPageChange={this.handlePageChange} + /> + {items === null && ( + + + + )} + + + + ); } diff --git a/src/components/manifest/PartyFilterPanel.jsx b/src/components/manifest/PartyFilterPanel.jsx new file mode 100644 index 0000000..aa85ba2 --- /dev/null +++ b/src/components/manifest/PartyFilterPanel.jsx @@ -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 ( + + ); + } +} + +/** + * 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 ( + + ); + } +} + +/** + * 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 ( + + + {/* Header */} + + + {title} + + + + + + + {/* Quick filter input */} + + + + + ), + endAdornment: search ? ( + + e.preventDefault()} + > + + + + ) : null, + }, + }} + /> + + + {/* Parties list */} + + {sorted.length === 0 ? ( + + + Keine Treffer + + + ) : ( + 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 ( + 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', + }, + }, + }} + > + + {isSelected && ( + + )} + + ); + }) + )} + + + + ); + } +} diff --git a/src/components/manifest/QuoteList.jsx b/src/components/manifest/QuoteList.jsx index 21ce0d1..8392a2b 100644 --- a/src/components/manifest/QuoteList.jsx +++ b/src/components/manifest/QuoteList.jsx @@ -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)} /> + + {selectedCustomer && ( + + )} @@ -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 = ( + this.handleSelectCustomer(null)} + /> + ); + if (items === null && quoteAutomat.state.status === 'failed') { return ( <> - + {searchField} @@ -665,64 +731,79 @@ export class QuoteList extends PureComponent { return ( <> - - - - - {renderInvisibleDummyRow(this.headerItem)} - Nr. - Datum - Kunde - Pos. - - Gesamt - - - } - onPageChange={this.handlePageChange} + {searchField} + + {customerFilterOpen && ( + - {items === null && ( - - - - )} - - + )} + + + + + {renderInvisibleDummyRow(this.headerItem)} + Nr. + Datum + Kunde + Pos. + + Gesamt + + + } + onPageChange={this.handlePageChange} + /> + {items === null && ( + + + + )} + + + + ); } diff --git a/src/components/manifest/ShipmentList.jsx b/src/components/manifest/ShipmentList.jsx index 97f1033..38014b5 100644 --- a/src/components/manifest/ShipmentList.jsx +++ b/src/components/manifest/ShipmentList.jsx @@ -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)} /> + + {selectedCustomer && ( + + )} @@ -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 = ( + this.setState({ newShipmentModalOpen: true })} + customerFilterOpen={customerFilterOpen} + onToggleCustomerFilter={this.handleToggleCustomerFilter} + selectedCustomer={selectedCustomer} + onClearCustomerFilter={() => this.handleSelectCustomer(null)} + /> + ); + if (items === null && shipmentAutomat.state.status === 'failed') { return ( <> - this.setState({ newShipmentModalOpen: true })} /> + {searchField} @@ -1003,69 +1063,80 @@ export class ShipmentList extends PureComponent { return ( <> - this.setState({ newShipmentModalOpen: true })} - /> - - - - - {renderInvisibleDummyRow(this.headerItem)} - Lieferschein / Ref - Datum - Auftrag - Kunde - Pos. - Paket / Tracking - Status - - - } - onPageChange={this.handlePageChange} + {searchField} + + {customerFilterOpen && ( + - {items === null && ( - - - - )} - - + )} + + + + + {renderInvisibleDummyRow(this.headerItem)} + Lieferschein / Ref + Datum + Auftrag + Kunde + Pos. + Paket / Tracking + Status + + + } + onPageChange={this.handlePageChange} + /> + {items === null && ( + + + + )} + + + + {/* Modal: View / Manage Shipment Details */} {selectedShipment && ( diff --git a/src/components/manifest/index.js b/src/components/manifest/index.js index 12a760c..f3c8633 100644 --- a/src/components/manifest/index.js +++ b/src/components/manifest/index.js @@ -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,