diff --git a/src/automats/index.js b/src/automats/index.js index 456c8a1..3d47402 100644 --- a/src/automats/index.js +++ b/src/automats/index.js @@ -70,6 +70,13 @@ export { selectOrders, selectOrderTotal, selectOrderPagination, + selectOnlyUnpaid, + selectOnlyUndelivered, + selectNegativeSeekMonths, + isOrderPaid, + isOrderDelivered, + getNegativeSeekCutoff, + formatNegativeSeek, } from './orderAutomat'; export { default as entityAutomat } from './entityAutomat'; export { default as customergroupAutomat } from './customergroupAutomat'; diff --git a/src/automats/orderAutomat.js b/src/automats/orderAutomat.js index 2ea6d94..50a2fd8 100644 --- a/src/automats/orderAutomat.js +++ b/src/automats/orderAutomat.js @@ -49,6 +49,11 @@ import authAutomat from './authAutomat'; const initialState = { items: [], filter: '', + onlyUnpaid: false, + onlyUndelivered: false, + seekMonths: 0, + seekYears: 0, + negativeSeekMonths: 0, total: 0, offset: 0, limit: 20, @@ -63,6 +68,57 @@ let inflightLoad = null; let inflightMore = null; const FETCH_PAGE_SIZE = 100; +// MARK: pure order helpers +export function isOrderPaid(order) { + return Boolean(order.paid ?? order.payed ?? order.isPaid ?? order.is_paid ?? order.paidAt); +} + +export function isOrderDelivered(order) { + return Boolean(order.delivered ?? order.isDelivered ?? order.is_delivered ?? order.deliveredAt); +} + +export function getOrderDate(order) { + const raw = order.date || order.createdAt; + if (!raw) return null; + const d = new Date(raw); + return Number.isNaN(d.getTime()) ? null : d; +} + +/** + * Calculates cutoff date for negative seek. + * Negative seek filters orders older than or equal to (today + months). + * @param {number} months Negative integer (e.g. -1 for 1 month ago, -12 for 1 year ago) + * @returns {Date|null} + */ +export function getNegativeSeekCutoff(months) { + if (!months || months >= 0) return null; + const d = new Date(); + d.setMonth(d.getMonth() + months); + d.setHours(23, 59, 59, 999); + return d; +} + +/** + * Formats negative seek duration in human readable German. + * e.g. -1 -> "-1 Monat", -2 -> "-2 Monate", -12 -> "-1 Jahr", -13 -> "-1 Jahr 1 Monat" + * @param {number} months + * @returns {string} + */ +export function formatNegativeSeek(months) { + if (!months || months >= 0) return ''; + const total = Math.abs(months); + const years = Math.floor(total / 12); + const remMonths = total % 12; + const parts = []; + if (years > 0) { + parts.push(`${years} ${years === 1 ? 'Jahr' : 'Jahre'}`); + } + if (remMonths > 0) { + parts.push(`${remMonths} ${remMonths === 1 ? 'Monat' : 'Monate'}`); + } + return `-${parts.join(' ')}`; +} + // MARK: fetches a single page from GET /api/order async function fetchOrdersPage(offset = 0, limit = FETCH_PAGE_SIZE) { const res = await orderApi.list({ offset, limit }); @@ -72,17 +128,40 @@ async function fetchOrdersPage(offset = 0, limit = FETCH_PAGE_SIZE) { } /** - * Client-side filter over number, external reference and (resolved) customer name. + * Client-side filter over number, external reference, customer name, paid/delivered flags, and negative seek. * @param {Order[]} items - * @param {string} filter + * @param {string|object} filterOrOpts String filter query or options object { filter, onlyUnpaid, onlyUndelivered, negativeSeekMonths } * @param {(order: Order) => string} [nameOf] Customer name resolver. */ -export function filterOrders(items, filter, nameOf) { - const query = String(filter ?? '').trim().toLowerCase(); - if (!query) return items; - return items.filter((o) => - [o.number, o.externalNumber, nameOf?.(o)] - .some((v) => String(v ?? '').toLowerCase().includes(query))); +export function filterOrders(items, filterOrOpts, nameOf) { + if (!Array.isArray(items)) return []; + const opts = typeof filterOrOpts === 'string' + ? { filter: filterOrOpts } + : (filterOrOpts ?? {}); + + const query = String(opts.filter ?? '').trim().toLowerCase(); + const onlyUnpaid = Boolean(opts.onlyUnpaid); + const onlyUndelivered = Boolean(opts.onlyUndelivered); + const seekMonths = Number(opts.negativeSeekMonths ?? 0); + const cutoff = getNegativeSeekCutoff(seekMonths); + + return items.filter((o) => { + if (onlyUnpaid && isOrderPaid(o)) return false; + if (onlyUndelivered && isOrderDelivered(o)) return false; + if (cutoff) { + const orderDate = getOrderDate(o); + // Older than cutoff point: order date <= cutoff + if (!orderDate || orderDate.getTime() > cutoff.getTime()) { + return false; + } + } + if (query) { + const matches = [o.number, o.externalNumber, nameOf?.(o)] + .some((v) => String(v ?? '').toLowerCase().includes(query)); + if (!matches) return false; + } + return true; + }); } const actions = { @@ -91,6 +170,62 @@ const actions = { automat.setState({ filter: String(query ?? '') }); }, + /** Sets whether to filter for only unpaid orders. */ + setOnlyUnpaid(onlyUnpaid) { + automat.setState({ onlyUnpaid: Boolean(onlyUnpaid) }); + }, + + /** Sets whether to filter for only undelivered orders. */ + setOnlyUndelivered(onlyUndelivered) { + automat.setState({ onlyUndelivered: Boolean(onlyUndelivered) }); + }, + + /** Increments seek by 1 month. */ + addSeekMonth() { + const seekMonths = (automat.state.seekMonths ?? 0) + 1; + const seekYears = automat.state.seekYears ?? 0; + const totalMonths = -(seekMonths + seekYears * 12); + automat.setState({ seekMonths, negativeSeekMonths: totalMonths }); + }, + + /** Increments seek by 1 year. */ + addSeekYear() { + const seekMonths = automat.state.seekMonths ?? 0; + const seekYears = (automat.state.seekYears ?? 0) + 1; + const totalMonths = -(seekMonths + seekYears * 12); + automat.setState({ seekYears, negativeSeekMonths: totalMonths }); + }, + + /** Cumulative negative seek: adds months (e.g. -1 or -12) to current negativeSeekMonths. */ + addNegativeSeek(months) { + if (months === -12) { + actions.addSeekYear(); + } else if (months === -1) { + actions.addSeekMonth(); + } else { + const current = Math.min(0, Number(automat.state.negativeSeekMonths ?? 0)); + const next = current + months; + automat.setState({ negativeSeekMonths: Math.min(0, next) }); + } + }, + + /** Resets negative seek back to 0 (all dates). */ + resetNegativeSeek() { + automat.setState({ seekMonths: 0, seekYears: 0, negativeSeekMonths: 0 }); + }, + + /** Resets all filters. */ + resetFilters() { + automat.setState({ + filter: '', + onlyUnpaid: false, + onlyUndelivered: false, + seekMonths: 0, + seekYears: 0, + negativeSeekMonths: 0, + }); + }, + /** * Synchronously returns current items and triggers loading if idle. * On remounts, directly returns cached items from the automat. @@ -102,11 +237,8 @@ const actions = { if (automat.state.status === 'idle') { void actions.load().catch(() => {}); } - const { items, filter } = automat.state; - if (filter) { - return filterOrders(items, filter, nameOf); - } - return items; + const { items, filter, onlyUnpaid, onlyUndelivered, negativeSeekMonths } = automat.state; + return filterOrders(items, { filter, onlyUnpaid, onlyUndelivered, negativeSeekMonths }, nameOf); }, /** @@ -301,6 +433,31 @@ export function selectIsLoadingMoreOrders(state = automat.state) { return state.loadingMore; } +/** Selects unpaid filter */ +export function selectOnlyUnpaid(state = automat.state) { + return state.onlyUnpaid; +} + +/** Selects undelivered filter */ +export function selectOnlyUndelivered(state = automat.state) { + return state.onlyUndelivered; +} + +/** Selects negative seek offset in months */ +export function selectNegativeSeekMonths(state = automat.state) { + return state.negativeSeekMonths; +} + +/** Selects seek month count */ +export function selectSeekMonths(state = automat.state) { + return state.seekMonths ?? 0; +} + +/** Selects seek year count */ +export function selectSeekYears(state = automat.state) { + return state.seekYears ?? 0; +} + /** * Calculates client pagination metadata. * Current Page: Math.floor(offset / limit) + 1 diff --git a/src/components/manifest/OrderList.jsx b/src/components/manifest/OrderList.jsx index 00a1a01..4fb0d65 100644 --- a/src/components/manifest/OrderList.jsx +++ b/src/components/manifest/OrderList.jsx @@ -3,18 +3,26 @@ 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 Divider from '@mui/material/Divider'; +import FormControlLabel from '@mui/material/FormControlLabel'; import IconButton from '@mui/material/IconButton'; import ListItemButton from '@mui/material/ListItemButton'; import Typography from '@mui/material/Typography'; import Checkbox from '@mui/material/Checkbox'; -import Tooltip from '@mui/material/Tooltip'; +import Switch from '@mui/material/Switch'; import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined'; 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 orderAutomat, { filterOrders } from '../../automats/orderAutomat'; +import orderAutomat, { + filterOrders, + isOrderPaid, + isOrderDelivered, + getNegativeSeekCutoff, + formatNegativeSeek, +} from '../../automats/orderAutomat'; import { order as orderApi } from '../../api'; import { backendErrorFrom } from '../../utils/backendError'; import { notifyError } from '../../utils/notify'; @@ -37,14 +45,6 @@ function orderGrossTotal(lines) { }, 0); } -function isOrderPaid(order) { - return Boolean(order.paid ?? order.payed ?? order.isPaid ?? order.is_paid ?? order.paidAt); -} - -function isOrderDelivered(order) { - return Boolean(order.delivered ?? order.isDelivered ?? order.is_delivered ?? order.deliveredAt); -} - const cfg = manifestConfig('order'); const rowSx = { @@ -123,35 +123,27 @@ class OrderRow extends PureComponent { - - - e.stopPropagation()} - onChange={(e) => onTogglePaid?.(order, e.target.checked)} - inputProps={{ 'aria-label': `Auftrag ${order.number} bezahlt` }} - sx={{ p: 0.5 }} - /> - - + e.stopPropagation()} + onChange={(e) => onTogglePaid?.(order, e.target.checked)} + inputProps={{ 'aria-label': `Auftrag ${order.number} bezahlt` }} + sx={{ p: 0.5 }} + /> - - - e.stopPropagation()} - onChange={(e) => onToggleDelivered?.(order, e.target.checked)} - inputProps={{ 'aria-label': `Auftrag ${order.number} geliefert` }} - sx={{ p: 0.5 }} - /> - - + e.stopPropagation()} + onChange={(e) => onToggleDelivered?.(order, e.target.checked)} + inputProps={{ 'aria-label': `Auftrag ${order.number} geliefert` }} + sx={{ p: 0.5 }} + /> ) +// MARK: class OrderSearchField extends PureComponent { constructor(props) { super(props); - this.state = { input: orderAutomat.state.filter ?? '' }; + const s = orderAutomat.state; + this.state = { + input: s.filter ?? '', + onlyUnpaid: s.onlyUnpaid ?? false, + onlyUndelivered: s.onlyUndelivered ?? false, + seekMonths: s.seekMonths ?? 0, + seekYears: s.seekYears ?? 0, + negativeSeekMonths: s.negativeSeekMonths ?? 0, + }; } componentDidMount() { - this.unsub = orderAutomat.subscribe(this, (state) => ({ input: state.filter })); + this.unsub = orderAutomat.subscribe(this, (state) => ({ + input: state.filter ?? '', + onlyUnpaid: state.onlyUnpaid ?? false, + onlyUndelivered: state.onlyUndelivered ?? false, + seekMonths: state.seekMonths ?? 0, + seekYears: state.seekYears ?? 0, + negativeSeekMonths: state.negativeSeekMonths ?? 0, + })); } componentWillUnmount() { @@ -210,6 +217,12 @@ class OrderSearchField extends PureComponent { render() { const { inputRef, onCreate } = this.props; + const { input, onlyUnpaid, onlyUndelivered, 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 ( - orderAutomat.actions.filter(next)} - /> - + {/* Top row: search field & create button */} + + orderAutomat.actions.filter(next)} + /> + + + + {/* Filter Bar: "Nur ungezahlte", "Nur ungelieferte", Negativeseek (-1 Month, -1 Year, Reset) */} + + {/* Left: Unpaid & Undelivered toggles */} + + orderAutomat.actions.setOnlyUnpaid(e.target.checked)} + /> + } + label={ + + Nur ungezahlte + + } + sx={{ m: 0 }} + /> + + + + orderAutomat.actions.setOnlyUndelivered(e.target.checked)} + /> + } + label={ + + Nur ungelieferte + + } + sx={{ m: 0 }} + /> + + + {/* Right: Negativeseek controls (-1 Month, -1 Year, Reset) */} + + + + + + + + + + ); @@ -251,6 +413,11 @@ export class OrderList extends PureComponent { items: s.items ?? [], total: s.total ?? 0, filter: s.filter ?? '', + onlyUnpaid: s.onlyUnpaid ?? false, + onlyUndelivered: s.onlyUndelivered ?? false, + negativeSeekMonths: s.negativeSeekMonths ?? 0, + hasMore: s.hasMore ?? false, + loadingMore: s.loadingMore ?? false, partiesById: {}, busyId: null, }; @@ -309,9 +476,17 @@ export class OrderList extends PureComponent { items: state.items ?? [], total: state.total ?? 0, filter: state.filter ?? '', - filteredItems: state.filter - ? filterOrders(state.items ?? [], state.filter, this.nameOf) - : (state.items ?? []), + onlyUnpaid: state.onlyUnpaid ?? false, + onlyUndelivered: state.onlyUndelivered ?? false, + negativeSeekMonths: state.negativeSeekMonths ?? 0, + hasMore: state.hasMore ?? false, + loadingMore: state.loadingMore ?? false, + filteredItems: filterOrders(state.items ?? [], { + filter: state.filter, + onlyUnpaid: state.onlyUnpaid, + onlyUndelivered: state.onlyUndelivered, + negativeSeekMonths: state.negativeSeekMonths, + }, this.nameOf), status: state.status, error: state.error ? backendErrorFrom(state.error) : null, })); @@ -330,9 +505,12 @@ export class OrderList extends PureComponent { const partiesById = cfg.partiesByIdFromList(parties); this.setState((prev) => ({ partiesById, - filteredItems: prev.filter - ? filterOrders(prev.items, prev.filter, (o) => partyDisplayName('order', partiesById[o.entityId])) - : prev.filteredItems, + filteredItems: filterOrders(prev.items, { + filter: prev.filter, + onlyUnpaid: prev.onlyUnpaid, + onlyUndelivered: prev.onlyUndelivered, + negativeSeekMonths: prev.negativeSeekMonths, + }, (o) => partyDisplayName('order', partiesById[o.entityId])), })); } } catch { @@ -373,7 +551,21 @@ export class OrderList extends PureComponent { } render() { - const { status, error, items, total, filter, filteredItems, partiesById, busyId } = this.state; + const { + status, + error, + items, + total, + filter, + onlyUnpaid, + onlyUndelivered, + negativeSeekMonths, + hasMore, + loadingMore, + filteredItems, + partiesById, + busyId, + } = this.state; if (status === 'loading' || status === 'idle') { return ( @@ -394,58 +586,54 @@ export class OrderList extends PureComponent { rowContext.onToggleDelivered = this.handleToggleDelivered; const rows = filteredItems ?? []; - const displayTotal = filter ? rows.length : (total || items.length); + const isFiltered = Boolean(filter || onlyUnpaid || onlyUndelivered || negativeSeekMonths < 0); + const displayTotal = isFiltered ? rows.length : (total || items.length); return ( <> - {items.length === 0 ? ( - - {cfg.emptyList} - - ) : ( - - Nr. / Ref. - Datum - Kunde - Bezahlt - Geliefert - Pos. - Gesamt - - - } - onPageChange={this.handlePageChange} - /> - )} + + Nr. / Ref. + Datum + Kunde + Bezahlt + Geliefert + Pos. + Gesamt + + + } + onPageChange={this.handlePageChange} + />