import { Component, createRef } from 'react';
import FillSpace from 'dashgui/FillSpace';
import Box from '@mui/material/Box';
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 SearchIcon from '@mui/icons-material/Search';
import { StorageManagerContext, getStorageManager } from '../../api/storageManagerContext';
import BackendError from '../../components/BackendError';
import { formatPrice } from '../../utils/price';
import {
buildSearchUrl,
queryFromParams,
readArtikelQuery,
writeArtikelQuery,
} from './artikelQuery';
import ResultsPagination from './ResultsPagination';
import {
buildFilter,
formatCategoryList,
formatStock,
getCategoryDepth,
parseFilter,
} from './searchFormat';
import { preserveSearchFocus } from './searchFocus';
import { artikelDetailPath } from './articleRoute';
import withSearchParams from './withSearchParams';
import { setLastArticleSku } from '../../automats';
import { BrandLabel } from '../../components/brand';
const resultRowSx = {
alignItems: 'center',
py: 1.5,
// Keep every row one line so sample height matches real rows.
'& .MuiListItemText-root': { minWidth: 0 },
'& .MuiListItemText-primary, & .MuiListItemText-secondary': {
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
},
};
function ResultRowMeasureSample() {
return (
SKU · Bestand 0 · Verfügbar 0 · Kategorie
}
slotProps={{ primary: { fontWeight: 500, noWrap: true } }}
/>
0,00 €
);
}
function initialSearchState(searchParams) {
const query = queryFromParams(searchParams);
const stored = readArtikelQuery();
const input =
query.target.trim().length >= 3
? query.target
: (stored?.target?.trim().length >= 3 ? stored.target : query.target);
return {
input,
error: null,
categoryFacets: null,
totalCount: null,
recordset: null,
pageSize: null,
};
}
class ArtikelSearchPage extends Component {
static contextType = StorageManagerContext;
get storageManager() {
return this.context || getStorageManager();
}
state = initialSearchState(this.props.searchParams);
listSlotRef = { current: null };
rowMeasureRef = { current: null };
paginationRef = createRef();
setListSlotRef = (node) => {
if (this.listSlotRef.current === node) return;
this.listSlotRef.current = node;
if (!node) return;
this.updatePageSize();
};
setRowMeasureRef = (node) => {
if (this.rowMeasureRef.current === node) return;
this.rowMeasureRef.current = node;
if (!node) return;
this.updatePageSize();
};
measureRowHeight = () => {
// Always use the sample — real rows used to report a different height after paint
// (wrapped text), which made resize "fix" a wrong first measure.
const sample = this.rowMeasureRef.current;
if (!sample) return { height: 0, source: 'none' };
return {
height: sample.getBoundingClientRect().height,
source: 'sample',
};
};
updatePageSize = ({ fromResize = false } = {}) => {
const slot = this.listSlotRef.current;
if (!slot) return;
// After the first measure, ignore layout churn from painting results.
// Only window resize may change pageSize and refetch.
if (this.state.pageSize != null && !fromResize) return;
const slotTop = slot.getBoundingClientRect().top;
const available = window.innerHeight - slotTop;
const { height: rowHeight, source: rowSource } = this.measureRowHeight();
if (available < 1 || rowHeight < 1) return;
// Outlined Paper around the list is 1px top + 1px bottom — not in row height.
const listChrome = 2;
const next = Math.max(1, Math.floor((available - listChrome) / rowHeight));
console.log('[pageSize] measure', {
fromResize,
prevPageSize: this.state.pageSize,
next,
available,
rowHeight,
rowSource,
listChrome,
slotTop,
realRowHeight:
slot.querySelector('[data-result-row]')?.getBoundingClientRect().height ?? null,
});
if (next === this.state.pageSize) return;
this.setState({ pageSize: next }, () => {
const target = this.props.searchParams.get('target') ?? '';
if (target) this.fetchResults();
});
};
handleWindowResize = () => {
this.updatePageSize({ fromResize: true });
};
persistQuery = () => {
const query = queryFromParams(this.props.searchParams);
const target = query.target || this.state.input;
writeArtikelQuery({
target,
filter: query.filter,
offset: query.offset,
});
};
componentDidMount() {
document.addEventListener('mousedown', preserveSearchFocus, true);
window.addEventListener('resize', this.handleWindowResize);
setLastArticleSku(null);
// Kick off initial search (empty input = show all products).
const { input } = this.state;
this.storageManager?.doSearch({
target: input,
filter: this.props.searchParams.get('filter') || undefined,
offset: 0,
maxCount: this.state.pageSize ?? 20,
onUpdate: (result) => this.setState(result),
});
}
componentWillUnmount() {
document.removeEventListener('mousedown', preserveSearchFocus, true);
window.removeEventListener('resize', this.handleWindowResize);
this.persistQuery();
}
componentDidUpdate(prevProps) {
const { searchParams } = this.props;
const prev = prevProps.searchParams;
const target = searchParams.get('target') ?? '';
const prevTarget = prev.get('target') ?? '';
if (target !== prevTarget && target !== this.state.input) {
this.setState({ input: target });
}
if (
target !== prevTarget
|| searchParams.get('filter') !== prev.get('filter')
|| searchParams.get('offset') !== prev.get('offset')
) {
this.fetchResults();
}
}
updateParams = (nextTarget, nextFilter, nextOffset = 0) => {
writeArtikelQuery({
target: nextTarget,
filter: nextFilter,
offset: nextOffset,
});
this.props.navigate(buildSearchUrl(nextTarget, nextFilter, nextOffset), {
replace: true,
});
};
handleInputChange = (event) => {
const value = event.target.value;
this.setState({ input: value });
this.storageManager?.doSearch({
target: value,
filter: this.props.searchParams.get('filter') || undefined,
offset: 0,
maxCount: this.state.pageSize ?? 20,
onUpdate: (result) => this.setState(result),
});
};
fetchResults = () => {
const { target, filter, offset } = queryFromParams(this.props.searchParams);
const trimmed = target.trim();
const { pageSize } = this.state;
if (pageSize == null) return;
writeArtikelQuery({ target, filter, offset });
// Delegate fetch + debounce to the global StorageManager.
// The callback pushes results into local component state.
this.storageManager?.doSearch({
target: trimmed,
filter: filter || undefined,
offset,
maxCount: pageSize,
onUpdate: (result) => this.setState(result),
});
};
render() {
const { input, error, categoryFacets, totalCount, recordset, pageSize } = this.state;
const filterParam = this.props.searchParams.get('filter') ?? '';
const offset = Math.max(0, Number(this.props.searchParams.get('offset')) || 0);
const categories = parseFilter(filterParam);
// Drive UI from input so hint vs results switch in one paint (URL lags one tick).
const searchActive = input.trim().length > 0 || (recordset ?? []).length > 0;
const rows = recordset ?? [];
const facetEntries = categoryFacets
? Object.entries(categoryFacets)
.filter(([, meta]) => meta?.name)
.map(([id, { count, name }]) => ({
id,
count,
name,
depth: getCategoryDepth(id, categoryFacets),
}))
.sort((a, b) => Number(a.id) - Number(b.id))
: [];
const safePageSize = pageSize ?? 1;
const pageCount = Math.max(1, Math.ceil((totalCount ?? 0) / safePageSize));
const currentPage = Math.floor(offset / safePageSize) + 1;
const showPagination =
rows.length > 0 && pageSize != null && totalCount != null && totalCount > pageSize;
const handlePageChange = (_e, page) => {
this.updateParams(input, filterParam, (page - 1) * safePageSize);
};
return (
<>
),
},
}}
/>
{error && (
)}
{searchActive && (
{facetEntries.length > 0 && (
Kategorien
{facetEntries.map(({ id, count, name, depth }) => {
const selected = categories.includes(id);
return (
e.preventDefault()}
onClick={() => {
const next = selected ? [] : [id];
this.updateParams(input, buildFilter(next), 0);
}}
sx={{ pl: 2 + depth * 1.5 }}
>
{selected && (
)}
);
})}
)}
{/* Same column width as the list; invisible, zero layout footprint. */}
{searchActive && !error && totalCount === 0 && (
Keine Artikel gefunden.
)}
{/* Always mount real pagination height (invisible until needed) so pageSize is correct. */}
Einheit
}
>
{rows.length > 0 && (
{rows.map((row, index) => (
e.preventDefault()}
onClick={() => {
this.persistQuery();
this.props.navigate(artikelDetailPath(row));
}}
sx={resultRowSx}
>
{(row.brand?.name || row.brandName) && (
<>
{' · '}
>
)}
{row.sku}
{' · '}
Bestand {formatStock(row.stock)}
{' · '}
Verfügbar {formatStock(row.available)}
{row.categories
? ` · ${formatCategoryList(row.categories, categoryFacets ?? {})}`
: ''}
}
slotProps={{ primary: { fontWeight: 500, noWrap: true } }}
/>
{row.price != null ? formatPrice(row.price) : '—'}
))}
)}
)}
>
);
}
}
export default withSearchParams(ArtikelSearchPage);