From 14ae017b64edb53d987950f6e2be82c949af3eac Mon Sep 17 00:00:00 2001 From: seb Date: Fri, 9 Oct 2026 19:44:55 +0200 Subject: [PATCH] u --- src/api/index.js | 19 +++- src/automats/brandAutomat.js | 39 ++++++--- src/components/brand/ArtikelMarke.jsx | 2 +- src/components/brand/ArtikelMarkenTable.jsx | 2 +- src/routes/artikel/marken.jsx | 96 +++++++++++++++++---- 5 files changed, 125 insertions(+), 33 deletions(-) diff --git a/src/api/index.js b/src/api/index.js index b4d9817..8aea2fc 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -838,12 +838,23 @@ export const category = { /** * Company-scoped brands (selectedCompanyId from session). - * Shape: Brand = { id, name, gsprpinfo | null } + * Shape: Brand = { id, name, gsprpinfo | null, pics: BrandPic[] } */ export const brand = { - /** GET /api/brand → { brands: Brand[] } · 400 No company selected */ - list() { - return http('/api/brand'); + /** + * GET /api/brand → { brands: Brand[], total, offset, limit } + * @param {{ offset?: number, limit?: number, includeSmallThumbs?: boolean }} [params] + */ + list(params) { + const offset = Number(params?.offset ?? 0); + const limit = Number(params?.limit ?? 0); + const includeSmallThumbs = params?.includeSmallThumbs; + const query = new URLSearchParams(); + if (Number.isFinite(offset) && offset > 0) query.set('offset', String(offset)); + if (Number.isFinite(limit) && limit > 0) query.set('limit', String(limit)); + if (includeSmallThumbs != null) query.set('includeSmallThumbs', includeSmallThumbs ? 'true' : 'false'); + const qs = query.toString(); + return http(qs ? `/api/brand?${qs}` : '/api/brand'); }, /** diff --git a/src/automats/brandAutomat.js b/src/automats/brandAutomat.js index 88e2724..b2d0111 100644 --- a/src/automats/brandAutomat.js +++ b/src/automats/brandAutomat.js @@ -21,7 +21,8 @@ import sseAutomat, { getSseClientId } from './sseAutomat'; /** * @typedef {Object} BrandState - * @property {Array} brands + * @property {Array} brands All brands (full list, decoupled from page UI). + * @property {number} total Unpaged brand count. * @property {'idle'|'loading'|'succeeded'|'failed'} status * @property {string|null} error */ @@ -29,6 +30,7 @@ import sseAutomat, { getSseClientId } from './sseAutomat'; /** @type {BrandState} */ const initialState = { brands: [], + total: 0, status: 'idle', error: null, }; @@ -44,20 +46,33 @@ let brandAutomat; /** In-flight `load()`/`reload()` promise so concurrent callers share one request. */ let inflightLoad = null; -async function fetchBrands() { - const res = await brandApi.list(); - return sortBrands(Array.isArray(res.brands) ? res.brands : []); +/** Max page size the API accepts; full list is fetched by paging through. */ +const FETCH_PAGE_SIZE = 100; + +async function fetchAllBrands() { + let offset = 0; + let total = Infinity; + const collected = []; + for (;;) { + const res = await brandApi.list({ offset, limit: FETCH_PAGE_SIZE }); + if (offset === 0) total = Number(res.total ?? res.brands?.length ?? 0); + const page = Array.isArray(res.brands) ? res.brands : []; + collected.push(...page); + offset += page.length; + if (page.length === 0 || collected.length >= total) break; + } + return { brands: sortBrands(collected), total: Number.isFinite(total) ? total : collected.length }; } const actions = { - /** Load all brands for the selected company. */ + /** Load all brands for the selected company (pages through the API). */ async load() { if (inflightLoad) return inflightLoad; brandAutomat.setState({ status: 'loading', error: null }); inflightLoad = (async () => { try { - const brands = await fetchBrands(); - brandAutomat.setState({ brands, status: 'succeeded' }); + const { brands, total } = await fetchAllBrands(); + brandAutomat.setState({ brands, total, status: 'succeeded' }); return brands; } catch (err) { brandAutomat.setState({ status: 'failed', error: err?.message ?? String(err) }); @@ -70,7 +85,7 @@ const actions = { }, /** - * Silently re-fetches the brand list while keeping the current state + * Silently re-fetches all brands while keeping the current state * visible (no loading flash). Subscribed views re-render from the updated * state — this is the reload pattern for SSE-driven refreshes. * @@ -80,8 +95,8 @@ const actions = { if (inflightLoad) return inflightLoad; inflightLoad = (async () => { try { - const brands = await fetchBrands(); - brandAutomat.setState({ brands, status: 'succeeded', error: null }); + const { brands, total } = await fetchAllBrands(); + brandAutomat.setState({ brands, total, status: 'succeeded', error: null }); return brands; } catch (err) { brandAutomat.setState({ error: err?.message ?? String(err) }); @@ -104,7 +119,7 @@ const actions = { ...brandAutomat.state.brands.filter((b) => b.id !== row.id), row, ]); - brandAutomat.setState({ brands }); + brandAutomat.setState({ brands, total: brands.length }); return row; }, @@ -130,7 +145,7 @@ const actions = { async remove(id) { await brandApi.delete(id); const brands = brandAutomat.state.brands.filter((b) => b.id !== id); - brandAutomat.setState({ brands }); + brandAutomat.setState({ brands, total: brands.length }); }, /** diff --git a/src/components/brand/ArtikelMarke.jsx b/src/components/brand/ArtikelMarke.jsx index cf8a0f0..d9e4cad 100644 --- a/src/components/brand/ArtikelMarke.jsx +++ b/src/components/brand/ArtikelMarke.jsx @@ -74,7 +74,7 @@ class ArtikelMarke extends PureComponent { return ( + {brands.length === 0 ? ( {emptyMessage} diff --git a/src/routes/artikel/marken.jsx b/src/routes/artikel/marken.jsx index c48a985..85e3fc6 100644 --- a/src/routes/artikel/marken.jsx +++ b/src/routes/artikel/marken.jsx @@ -1,14 +1,16 @@ -import { PureComponent } from 'react'; +import { PureComponent, createRef } from 'react'; import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; import CircularProgress from '@mui/material/CircularProgress'; import BackendError from '../../components/BackendError'; import SingleLineTextEdit from 'dashgui/SingleLineTextEdit'; +import FillSpace from 'dashgui/FillSpace'; import { PanelTile, PanelTileGrid } from 'dashgui/panels'; import { backendErrorFrom } from '../../utils/backendError'; import { notifyError } from '../../utils/notify'; import { setLastArticleSku } from '../../automats'; import brandAutomat from '../../automats/brandAutomat'; +import ResultsPagination from '../../components/article/ResultsPagination'; import { enqueueSnackbar } from 'notistack'; import { BrandImageSearchDialog, @@ -19,6 +21,15 @@ import { PURPLE_PANEL, } from '../../colors'; +function BrandRowMeasureSample() { + // Same component as real rows so the measured height matches exactly. + return ( + + + + ); +} + class ArtikelMarkenPage extends PureComponent { constructor(props) { super(props); @@ -26,17 +37,24 @@ class ArtikelMarkenPage extends PureComponent { status: brandAutomat.state.status === 'succeeded' ? 'ready' : 'loading', error: brandAutomat.state.error ? backendErrorFrom({ message: brandAutomat.state.error }) : null, brands: brandAutomat.state.brands || [], + pageSize: 10, + currentPage: 1, newName: '', busyKey: null, searchBrand: null, }; this._cancelled = false; + this.fillSpaceRef = createRef(); + this.paginationRef = createRef(); + this.createRowRef = createRef(); this.handleCreate = this.handleCreate.bind(this); this.handleRetry = this.handleRetry.bind(this); this.loadBrands = this.loadBrands.bind(this); this.handleRename = this.handleRename.bind(this); this.handleDelete = this.handleDelete.bind(this); this.handleSearchLogo = this.handleSearchLogo.bind(this); + this.handleMeasurement = this.handleMeasurement.bind(this); + this.handlePageChange = this.handlePageChange.bind(this); } componentDidMount() { @@ -63,10 +81,15 @@ class ArtikelMarkenPage extends PureComponent { } applyBrands(brands) { - this.setState({ - status: 'ready', - error: null, - brands, + this.setState((state) => { + const total = brands.length; + const pageCount = Math.max(1, Math.ceil(total / Math.max(1, state.pageSize))); + return { + status: 'ready', + error: null, + brands, + currentPage: Math.min(state.currentPage, pageCount), + }; }); } @@ -84,6 +107,20 @@ class ArtikelMarkenPage extends PureComponent { } } + // FillSpace measures how many rows fit; page size is purely local — + // no fetch, the automat already holds the full list. + handleMeasurement(count) { + if (!count || count <= 0 || count === this.state.pageSize) return; + this.setState((state) => { + const pageCount = Math.max(1, Math.ceil(state.brands.length / count)); + return { pageSize: count, currentPage: Math.min(state.currentPage, pageCount) }; + }); + } + + handlePageChange(_event, page) { + this.setState({ currentPage: Math.max(1, Number(page) || 1) }); + } + handleRetry() { void this.loadBrands(); } @@ -144,8 +181,14 @@ class ArtikelMarkenPage extends PureComponent { } render() { - const { status, error, brands, newName, busyKey } = this.state; + const { status, error, brands, pageSize, currentPage, newName, busyKey } = this.state; const busy = busyKey != null; + const total = brands.length; + const pageCount = Math.max(1, Math.ceil(total / Math.max(1, pageSize))); + const safePage = Math.min(Math.max(1, currentPage), pageCount); + const offset = (safePage - 1) * pageSize; + const pageBrands = brands.slice(offset, offset + pageSize); + const showPagination = total > pageSize; if (status === 'loading') { return ( @@ -161,16 +204,39 @@ class ArtikelMarkenPage extends PureComponent { return ( - - + + + + + } + minHeight={0} + sx={{ overflowX: 'hidden', overflowY: 'auto', width: '100%' }} + > + + - +