This commit is contained in:
seb
2026-10-09 19:44:55 +02:00
parent 645a9153a0
commit 14ae017b64
5 changed files with 125 additions and 33 deletions

View File

@@ -838,12 +838,23 @@ export const category = {
/** /**
* Company-scoped brands (selectedCompanyId from session). * Company-scoped brands (selectedCompanyId from session).
* Shape: Brand = { id, name, gsprpinfo | null } * Shape: Brand = { id, name, gsprpinfo | null, pics: BrandPic[] }
*/ */
export const brand = { export const brand = {
/** GET /api/brand → { brands: Brand[] } · 400 No company selected */ /**
list() { * GET /api/brand → { brands: Brand[], total, offset, limit }
return http('/api/brand'); * @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');
}, },
/** /**

View File

@@ -21,7 +21,8 @@ import sseAutomat, { getSseClientId } from './sseAutomat';
/** /**
* @typedef {Object} BrandState * @typedef {Object} BrandState
* @property {Array<object>} brands * @property {Array<object>} brands All brands (full list, decoupled from page UI).
* @property {number} total Unpaged brand count.
* @property {'idle'|'loading'|'succeeded'|'failed'} status * @property {'idle'|'loading'|'succeeded'|'failed'} status
* @property {string|null} error * @property {string|null} error
*/ */
@@ -29,6 +30,7 @@ import sseAutomat, { getSseClientId } from './sseAutomat';
/** @type {BrandState} */ /** @type {BrandState} */
const initialState = { const initialState = {
brands: [], brands: [],
total: 0,
status: 'idle', status: 'idle',
error: null, error: null,
}; };
@@ -44,20 +46,33 @@ let brandAutomat;
/** In-flight `load()`/`reload()` promise so concurrent callers share one request. */ /** In-flight `load()`/`reload()` promise so concurrent callers share one request. */
let inflightLoad = null; let inflightLoad = null;
async function fetchBrands() { /** Max page size the API accepts; full list is fetched by paging through. */
const res = await brandApi.list(); const FETCH_PAGE_SIZE = 100;
return sortBrands(Array.isArray(res.brands) ? res.brands : []);
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 = { const actions = {
/** Load all brands for the selected company. */ /** Load all brands for the selected company (pages through the API). */
async load() { async load() {
if (inflightLoad) return inflightLoad; if (inflightLoad) return inflightLoad;
brandAutomat.setState({ status: 'loading', error: null }); brandAutomat.setState({ status: 'loading', error: null });
inflightLoad = (async () => { inflightLoad = (async () => {
try { try {
const brands = await fetchBrands(); const { brands, total } = await fetchAllBrands();
brandAutomat.setState({ brands, status: 'succeeded' }); brandAutomat.setState({ brands, total, status: 'succeeded' });
return brands; return brands;
} catch (err) { } catch (err) {
brandAutomat.setState({ status: 'failed', error: err?.message ?? String(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 * visible (no loading flash). Subscribed views re-render from the updated
* state — this is the reload pattern for SSE-driven refreshes. * state — this is the reload pattern for SSE-driven refreshes.
* *
@@ -80,8 +95,8 @@ const actions = {
if (inflightLoad) return inflightLoad; if (inflightLoad) return inflightLoad;
inflightLoad = (async () => { inflightLoad = (async () => {
try { try {
const brands = await fetchBrands(); const { brands, total } = await fetchAllBrands();
brandAutomat.setState({ brands, status: 'succeeded', error: null }); brandAutomat.setState({ brands, total, status: 'succeeded', error: null });
return brands; return brands;
} catch (err) { } catch (err) {
brandAutomat.setState({ error: err?.message ?? String(err) }); brandAutomat.setState({ error: err?.message ?? String(err) });
@@ -104,7 +119,7 @@ const actions = {
...brandAutomat.state.brands.filter((b) => b.id !== row.id), ...brandAutomat.state.brands.filter((b) => b.id !== row.id),
row, row,
]); ]);
brandAutomat.setState({ brands }); brandAutomat.setState({ brands, total: brands.length });
return row; return row;
}, },
@@ -130,7 +145,7 @@ const actions = {
async remove(id) { async remove(id) {
await brandApi.delete(id); await brandApi.delete(id);
const brands = brandAutomat.state.brands.filter((b) => b.id !== id); const brands = brandAutomat.state.brands.filter((b) => b.id !== id);
brandAutomat.setState({ brands }); brandAutomat.setState({ brands, total: brands.length });
}, },
/** /**

View File

@@ -74,7 +74,7 @@ class ArtikelMarke extends PureComponent {
return ( return (
<ListItem <ListItem
disableGutters disableGutters
sx={{ gap: 1, py: 0.75, alignItems: 'center', ...sx }} sx={{ gap: 1, py: 0.75, alignItems: 'center', width: '100%', minWidth: 0, ...sx }}
> >
<BrandThumb <BrandThumb
brand={brand} brand={brand}

View File

@@ -19,7 +19,7 @@ class ArtikelMarkenTable extends PureComponent {
const isBusy = disabled || busyKey != null; const isBusy = disabled || busyKey != null;
return ( return (
<List dense disablePadding sx={sx}> <List dense disablePadding sx={{ width: '100%', minWidth: 0, ...sx }}>
{brands.length === 0 ? ( {brands.length === 0 ? (
<Typography variant="body2" color="text.secondary" sx={{ py: 1 }}> <Typography variant="body2" color="text.secondary" sx={{ py: 1 }}>
{emptyMessage} {emptyMessage}

View File

@@ -1,14 +1,16 @@
import { PureComponent } from 'react'; import { PureComponent, createRef } from 'react';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';
import CircularProgress from '@mui/material/CircularProgress'; import CircularProgress from '@mui/material/CircularProgress';
import BackendError from '../../components/BackendError'; import BackendError from '../../components/BackendError';
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit'; import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
import FillSpace from 'dashgui/FillSpace';
import { PanelTile, PanelTileGrid } from 'dashgui/panels'; import { PanelTile, PanelTileGrid } from 'dashgui/panels';
import { backendErrorFrom } from '../../utils/backendError'; import { backendErrorFrom } from '../../utils/backendError';
import { notifyError } from '../../utils/notify'; import { notifyError } from '../../utils/notify';
import { setLastArticleSku } from '../../automats'; import { setLastArticleSku } from '../../automats';
import brandAutomat from '../../automats/brandAutomat'; import brandAutomat from '../../automats/brandAutomat';
import ResultsPagination from '../../components/article/ResultsPagination';
import { enqueueSnackbar } from 'notistack'; import { enqueueSnackbar } from 'notistack';
import { import {
BrandImageSearchDialog, BrandImageSearchDialog,
@@ -19,6 +21,15 @@ import {
PURPLE_PANEL, PURPLE_PANEL,
} from '../../colors'; } from '../../colors';
function BrandRowMeasureSample() {
// Same component as real rows so the measured height matches exactly.
return (
<Box sx={{ pointerEvents: 'none', userSelect: 'none', opacity: 0.2 }}>
<ArtikelMarke brand={{ id: 'measure', name: ' ' }} disabled />
</Box>
);
}
class ArtikelMarkenPage extends PureComponent { class ArtikelMarkenPage extends PureComponent {
constructor(props) { constructor(props) {
super(props); super(props);
@@ -26,17 +37,24 @@ class ArtikelMarkenPage extends PureComponent {
status: brandAutomat.state.status === 'succeeded' ? 'ready' : 'loading', status: brandAutomat.state.status === 'succeeded' ? 'ready' : 'loading',
error: brandAutomat.state.error ? backendErrorFrom({ message: brandAutomat.state.error }) : null, error: brandAutomat.state.error ? backendErrorFrom({ message: brandAutomat.state.error }) : null,
brands: brandAutomat.state.brands || [], brands: brandAutomat.state.brands || [],
pageSize: 10,
currentPage: 1,
newName: '', newName: '',
busyKey: null, busyKey: null,
searchBrand: null, searchBrand: null,
}; };
this._cancelled = false; this._cancelled = false;
this.fillSpaceRef = createRef();
this.paginationRef = createRef();
this.createRowRef = createRef();
this.handleCreate = this.handleCreate.bind(this); this.handleCreate = this.handleCreate.bind(this);
this.handleRetry = this.handleRetry.bind(this); this.handleRetry = this.handleRetry.bind(this);
this.loadBrands = this.loadBrands.bind(this); this.loadBrands = this.loadBrands.bind(this);
this.handleRename = this.handleRename.bind(this); this.handleRename = this.handleRename.bind(this);
this.handleDelete = this.handleDelete.bind(this); this.handleDelete = this.handleDelete.bind(this);
this.handleSearchLogo = this.handleSearchLogo.bind(this); this.handleSearchLogo = this.handleSearchLogo.bind(this);
this.handleMeasurement = this.handleMeasurement.bind(this);
this.handlePageChange = this.handlePageChange.bind(this);
} }
componentDidMount() { componentDidMount() {
@@ -63,10 +81,15 @@ class ArtikelMarkenPage extends PureComponent {
} }
applyBrands(brands) { applyBrands(brands) {
this.setState({ this.setState((state) => {
status: 'ready', const total = brands.length;
error: null, const pageCount = Math.max(1, Math.ceil(total / Math.max(1, state.pageSize)));
brands, 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() { handleRetry() {
void this.loadBrands(); void this.loadBrands();
} }
@@ -144,8 +181,14 @@ class ArtikelMarkenPage extends PureComponent {
} }
render() { render() {
const { status, error, brands, newName, busyKey } = this.state; const { status, error, brands, pageSize, currentPage, newName, busyKey } = this.state;
const busy = busyKey != null; 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') { if (status === 'loading') {
return ( return (
@@ -161,16 +204,39 @@ class ArtikelMarkenPage extends PureComponent {
return ( return (
<PanelTileGrid> <PanelTileGrid>
<PanelTile wide={2} minHeight={280} color={PURPLE_PANEL}> <PanelTile wide={2} minHeight={0} color={PURPLE_PANEL} sx={{ pb: 0 }}>
<ArtikelMarkenTable <Box ref={this.paginationRef} sx={{ mb: 1 }}>
brands={brands} <ResultsPagination
busyKey={busyKey} invisible={!showPagination}
onRename={this.handleRename} offset={showPagination ? offset : 0}
onSearchLogo={this.handleSearchLogo} totalCount={showPagination ? total : pageSize + 1}
onDelete={this.handleDelete} pageSize={pageSize}
/> pageCount={showPagination ? pageCount : 1}
currentPage={showPagination ? safePage : 1}
onPageChange={this.handlePageChange}
/>
</Box>
<FillSpace
ref={this.fillSpaceRef}
aboveRef={this.paginationRef}
belowRef={this.createRowRef}
reserveBottom={32}
onMeasurement={this.handleMeasurement}
measureElement={<BrandRowMeasureSample />}
minHeight={0}
sx={{ overflowX: 'hidden', overflowY: 'auto', width: '100%' }}
>
<ArtikelMarkenTable
brands={pageBrands}
busyKey={busyKey}
onRename={this.handleRename}
onSearchLogo={this.handleSearchLogo}
onDelete={this.handleDelete}
sx={{ width: '100%' }}
/>
</FillSpace>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 2 }}> <Box ref={this.createRowRef} sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 2, pb: 2 }}>
<SingleLineTextEdit <SingleLineTextEdit
size="small" size="small"
label="Neue Marke" label="Neue Marke"