diff --git a/src/api/thumb.js b/src/api/thumb.js index b14d864..80af253 100644 --- a/src/api/thumb.js +++ b/src/api/thumb.js @@ -12,4 +12,10 @@ export const thumb = { return `/api/thumb/${encodeURIComponent(fullHash)}`; }, // #endregion + + // #region GET /api/smallthumb/:fullHash — smallUrl + smallUrl(fullHash) { + return `/api/smallthumb/${encodeURIComponent(fullHash)}`; + }, + // #endregion }; diff --git a/src/components/article/ArticleNameDialog.jsx b/src/components/article/ArticleNameDialog.jsx index 1f9afce..9e3e665 100644 --- a/src/components/article/ArticleNameDialog.jsx +++ b/src/components/article/ArticleNameDialog.jsx @@ -31,7 +31,7 @@ class ArticleNameDialog extends Component { super(props); this.state = { mode: SINGLE_MODE, - name: '', + name: props.initialName ?? '', bulkText: '', busy: false, error: null, @@ -46,7 +46,7 @@ class ArticleNameDialog extends Component { if (this.props.open && !prevProps.open) { this.setState({ mode: SINGLE_MODE, - name: '', + name: this.props.initialName ?? '', bulkText: '', busy: false, error: null, diff --git a/src/components/article/ArticlePhoto.jsx b/src/components/article/ArticlePhoto.jsx index 85e5414..30477b1 100644 --- a/src/components/article/ArticlePhoto.jsx +++ b/src/components/article/ArticlePhoto.jsx @@ -9,14 +9,17 @@ import { class ArticlePhoto extends Component { constructor(props) { super(props); - this.state = { thumbLoaded: false }; + this.state = { thumbLoaded: false, smallThumbError: false }; this.handleThumbLoad = this.handleThumbLoad.bind(this); this.handleThumbError = this.handleThumbError.bind(this); } componentDidUpdate(prevProps) { - if (prevProps.photo?.fullHash !== this.props.photo?.fullHash) { - this.setState({ thumbLoaded: false }); + if ( + prevProps.photo?.fullHash !== this.props.photo?.fullHash || + prevProps.useSmallThumb !== this.props.useSmallThumb + ) { + this.setState({ thumbLoaded: false, smallThumbError: false }); } } @@ -25,7 +28,11 @@ class ArticlePhoto extends Component { } handleThumbError() { - this.setState({ thumbLoaded: false }); + if (this.props.useSmallThumb && !this.state.smallThumbError) { + this.setState({ smallThumbError: true, thumbLoaded: false }); + } else { + this.setState({ thumbLoaded: false }); + } } render() { @@ -34,9 +41,11 @@ class ArticlePhoto extends Component { size = 200, fill = false, objectFit = fill ? 'cover' : 'contain', + useSmallThumb = false, sx, + ...rest } = this.props; - const { thumbLoaded } = this.state; + const { thumbLoaded, smallThumbError } = this.state; const fullHash = photo?.fullHash; const hasPreview = photo?.thumbHash != null; const aspectRatio = @@ -44,8 +53,15 @@ class ArticlePhoto extends Component { ? `${photo.thumbW} / ${photo.thumbH}` : '1 / 1'; + const imgSrc = fullHash + ? useSmallThumb && !smallThumbError + ? thumb.smallUrl(fullHash) + : thumb.url(fullHash) + : null; + return ( ) : null} - {fullHash ? ( + {imgSrc ? ( p.fullHash === selectedHash)) || + primary; + const otherPhotos = photos.filter((p) => p.fullHash !== activePhoto?.fullHash); return ( <> @@ -47,7 +68,86 @@ class ArticlePhotoTile extends PureComponent { busy: false, }} > - + {otherPhotos.length === 0 ? ( + + ) : ( + + + + {/* Smallthumbs of all other images beside the main image */} + + {otherPhotos.map((p) => ( + { + e.stopPropagation(); + this.handleSelectPhoto(p.fullHash); + }} + sx={{ + p: 0, + m: 0, + width: 40, + height: 40, + flexShrink: 0, + cursor: 'pointer', + borderRadius: 1, + overflow: 'hidden', + border: '2px solid transparent', + bgcolor: 'transparent', + outline: 'none', + display: 'block', + transition: 'all 0.15s ease', + '&:hover': { + transform: 'scale(1.08)', + borderColor: 'primary.main', + boxShadow: 2, + }, + '&:focus-visible': { + borderColor: 'primary.main', + }, + }} + > + + + ))} + + + )} { - this.setState(() => ({ - categories: this.props.categories ?? s.categories, - rootCategoryId: this.props.rootCategoryId ?? s.rootCategoryId, - })); + this._unsub = categoryAutomat.subscribe((s) => { + this.setState({ + categories: s.categories, + rootCategoryId: s.rootCategoryId ?? this.props.rootCategoryId, }); - } + }); } componentDidUpdate(prevProps) { @@ -64,10 +62,12 @@ export default class CategoryPickerDialog extends PureComponent { } else if (this.state.rootCategoryId != null) { nextSelectedId = this.state.rootCategoryId; } - this.setState({ selectedId: nextSelectedId }); - } - - if ( + this.setState({ + selectedId: nextSelectedId, + categories: categoryAutomat.state.categories ?? this.props.categories, + rootCategoryId: categoryAutomat.state.rootCategoryId ?? this.props.rootCategoryId, + }); + } else if ( this.props.categories !== prevProps.categories || this.props.rootCategoryId !== prevProps.rootCategoryId ) { diff --git a/src/components/article/boxes/ArticleViewLists.jsx b/src/components/article/boxes/ArticleViewLists.jsx index 0a2b55e..fe93ed0 100644 --- a/src/components/article/boxes/ArticleViewLists.jsx +++ b/src/components/article/boxes/ArticleViewLists.jsx @@ -123,6 +123,7 @@ export default class ArticleViewLists extends PureComponent { this.state = { attributes: initialAttributes, + attributesStatus: companyAutomat.state.attributesStatus || 'idle', assignedAttributes: [], allArticles: [], busy: false, @@ -170,10 +171,11 @@ export default class ArticleViewLists extends PureComponent { return { company: state.current, attributes: state.attributes, + attributesStatus: state.attributesStatus, selectedAttributeId: nextSelectedId, }; }); - }, ['current', 'attributes']); + }, ['current', 'attributes', 'attributesStatus']); this.unsubArtikel = artikelAutomat.subscribe(() => { if (this._cancelled) return; @@ -1602,7 +1604,7 @@ export default class ArticleViewLists extends PureComponent { } renderStandardView(selectableAttributes, assignedAttributeIds) { - const { busy, activeCreatorMode, selectedAttributeId } = this.state; + const { busy, activeCreatorMode, selectedAttributeId, attributes, attributesStatus } = this.state; return ( {activeCreatorMode === 'parent' ? ( @@ -1624,7 +1626,36 @@ export default class ArticleViewLists extends PureComponent { - {selectableAttributes.length === 0 ? ( + {attributesStatus === 'loading' ? ( + + + + Merkmale werden geladen… + + + ) : !attributes || attributes.length === 0 ? ( + + + Im System sind noch keine Merkmale angelegt. + + + Zu den Merkmalen → + + + ) : selectableAttributes.length === 0 ? ( Alle vorhandenen Merkmale sind diesem Artikel bereits zugewiesen. diff --git a/src/components/article/boxes/ArticleViewName.jsx b/src/components/article/boxes/ArticleViewName.jsx index 1abd021..50e49fb 100644 --- a/src/components/article/boxes/ArticleViewName.jsx +++ b/src/components/article/boxes/ArticleViewName.jsx @@ -1,7 +1,7 @@ import { Component, PureComponent } from 'react'; import { EditablePanelTile } from 'dashgui/panels'; import SingleLineTextEdit from 'dashgui/SingleLineTextEdit'; -import { getCategoryState } from '../../../automats/categoryAutomat'; +import categoryAutomat, { getCategoryState } from '../../../automats/categoryAutomat'; import artikelAutomat from '../../../automats/artikelAutomat'; import { getCategoryBreadcrumbs, categoryDisplayName } from '../../../utils/categoryUtils'; import { Box, Chip, IconButton, Typography } from '@mui/material'; @@ -92,10 +92,29 @@ function categoryLabels(categoryIds, categories, rootCategoryId) { .filter((c) => c.label); } -class CategoryPills extends Component { +class CategoryPills extends PureComponent { constructor(props) { super(props); - this.state = { pickerOpen: false, targetPillId: null }; + const catState = getCategoryState(); + this.state = { + pickerOpen: false, + targetPillId: null, + categories: catState?.categories ?? categoryAutomat.state.categories, + rootCategoryId: catState?.rootCategoryId ?? categoryAutomat.state.rootCategoryId, + }; + } + + componentDidMount() { + this._unsubCat = categoryAutomat.subscribe((s) => { + this.setState({ + categories: s.categories, + rootCategoryId: s.rootCategoryId, + }); + }); + } + + componentWillUnmount() { + this._unsubCat?.(); } handleRemoveCategory = (removeId) => { @@ -135,8 +154,7 @@ class CategoryPills extends Component { render() { const { categoryIds, editMode } = this.props; - const { pickerOpen, targetPillId } = this.state; - const { categories, rootCategoryId } = getCategoryState() ?? {}; + const { pickerOpen, targetPillId, categories, rootCategoryId } = this.state; const labels = categoryLabels(categoryIds, categories, rootCategoryId); const targetNode = categories && targetPillId != null ? categories[String(targetPillId)] : null; diff --git a/src/components/article/tabs/ArticleAttributesTab.jsx b/src/components/article/tabs/ArticleAttributesTab.jsx index 983b186..c77f530 100644 --- a/src/components/article/tabs/ArticleAttributesTab.jsx +++ b/src/components/article/tabs/ArticleAttributesTab.jsx @@ -1,4 +1,6 @@ import { PureComponent } from 'react'; +import { Link as RouterLink } from 'react-router-dom'; +import Link from '@mui/material/Link'; import Box from '@mui/material/Box'; import CircularProgress from '@mui/material/CircularProgress'; import IconButton from '@mui/material/IconButton'; @@ -562,6 +564,27 @@ export default class ArticleAttributesTab extends PureComponent { + {merkmalOptions.length === 0 && ( + + + Im System sind noch keine Merkmale angelegt. + + + Zu den Merkmalen → + + + )} + {/* Unified Assigned Merkmale List */} {assignedGroups.length > 0 && ( diff --git a/src/components/category/CategoryTree.jsx b/src/components/category/CategoryTree.jsx index 0d43f96..c179d79 100644 --- a/src/components/category/CategoryTree.jsx +++ b/src/components/category/CategoryTree.jsx @@ -416,14 +416,12 @@ export default class CategoryTree extends PureComponent { } componentDidMount() { - if (this.props.categories === undefined || this.props.rootCategoryId === undefined) { - this._unsub = categoryAutomat.subscribe((s) => { - this.setState({ - categories: this.props.categories ?? s.categories, - rootCategoryId: this.props.rootCategoryId ?? s.rootCategoryId, - }); + this._unsub = categoryAutomat.subscribe((s) => { + this.setState({ + categories: s.categories, + rootCategoryId: s.rootCategoryId ?? this.props.rootCategoryId, }); - } + }); } componentDidUpdate(prevProps) { @@ -463,7 +461,14 @@ export default class CategoryTree extends PureComponent { name, }); enqueueSnackbar(`Kategorie „${name}“ angelegt.`, { variant: 'success' }); - this.setState({ addDialogOpen: false }); + const nextCategories = categoryAutomat.state.categories ?? { + ...(this.state.categories || {}), + [String(created.id)]: created, + }; + this.setState({ + addDialogOpen: false, + categories: nextCategories, + }); this.props.onSelect?.(created.id, created); } catch (err) { notifyError(err); @@ -489,12 +494,19 @@ export default class CategoryTree extends PureComponent { if (!name || !renameNode) return; this.setState({ isSubmitting: true }); try { - await categoryAutomat.actions.renameCategory({ + const updated = await categoryAutomat.actions.renameCategory({ categoryId: renameNode.id, name, }); enqueueSnackbar(`Kategorie in „${name}“ umbenannt.`, { variant: 'success' }); - this.setState({ renameDialogOpen: false }); + const nextCategories = categoryAutomat.state.categories ?? { + ...(this.state.categories || {}), + [String(updated.id)]: updated, + }; + this.setState({ + renameDialogOpen: false, + categories: nextCategories, + }); } catch (err) { notifyError(err); } finally { @@ -524,7 +536,10 @@ export default class CategoryTree extends PureComponent { if (this.props.selectedId != null && String(this.props.selectedId) === String(deleteNode.id)) { this.props.onSelect?.(deleteNode.parentId ?? rootCategoryId); } - this.setState({ deleteDialogOpen: false }); + this.setState({ + deleteDialogOpen: false, + categories: categoryAutomat.state.categories, + }); } catch (err) { notifyError(err); } finally { diff --git a/src/routes/artikel.jsx b/src/routes/artikel.jsx index 95d1417..20986bf 100644 --- a/src/routes/artikel.jsx +++ b/src/routes/artikel.jsx @@ -20,6 +20,7 @@ import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import ExpandLessIcon from '@mui/icons-material/ExpandLess'; import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'; import articleSearchAutomat from '../automats/articleSearchAutomat'; +import articleIndexAutomat from '../automats/articleIndexAutomat'; import BackendError from '../components/BackendError'; import { formatPrice, getArticleGross } from '../utils/price'; import { thumb } from '../api'; @@ -50,6 +51,7 @@ import navigationAutomat, { import categoryAutomat from '../automats/categoryAutomat'; import companyAutomat from '../automats/companyAutomat'; import { BrandLabel, BrandThumb } from '../components/brand'; +import { ArticleNameDialog } from '../components/article'; import { SCRIM_LABEL } from '../colors'; // #endregion @@ -282,6 +284,7 @@ function initialSearchState(searchParams) { categoryFilterOpen: false, brandFilterOpen: false, supplierFilterOpen: false, + newArticleOpen: false, error: automatResult.error, categoryFacets: automatResult.categoryFacets, brandFacets: automatResult.brandFacets, @@ -354,6 +357,42 @@ class ArtikelSearchPage extends Component { })); }; + handleOpenNewArticle = () => { + this.setState({ newArticleOpen: true }); + }; + + handleCloseNewArticle = () => { + this.setState({ newArticleOpen: false }); + }; + + handleCreateArticle = async ({ names }) => { + const filterParam = this.props.searchParams.get('filter') ?? ''; + const categories = parseFilter(filterParam); + const parentId = + categories[0] || + categoryAutomat.state.rootCategoryId || + companyAutomat.state.current?.rootCategoryId || + companyAutomat.state.current?.rootcategory_id || + 1; + const list = (names ?? []).filter(Boolean); + if (list.length <= 1) { + const created = await categoryAutomat.actions.createArticle({ + parentId, + name: list[0] ?? '', + }); + void articleIndexAutomat.actions.reload().catch(() => {}); + if (created) { + this.props.navigate(artikelDetailPath(created)); + } + return; + } + const createdList = await categoryAutomat.actions.createArticles({ parentId, names: list }); + void articleIndexAutomat.actions.reload().catch(() => {}); + if (createdList?.[0]) { + this.props.navigate(artikelDetailPath(createdList[0])); + } + }; + handleOpenArticle = (row) => { this.persistQuery(); this.props.navigate(artikelDetailPath(row)); @@ -548,6 +587,7 @@ class ArtikelSearchPage extends Component { categoryFilterOpen, brandFilterOpen, supplierFilterOpen, + newArticleOpen, error, totalCount, categoryFacets, @@ -696,6 +736,13 @@ class ArtikelSearchPage extends Component { > Lieferanten + {categories.map((catId) => { const label = getCategoryBreadcrumbLabel( @@ -1047,6 +1094,12 @@ class ArtikelSearchPage extends Component { + ); }