diff --git a/src/api/category.js b/src/api/category.js index f33e119..7754301 100644 --- a/src/api/category.js +++ b/src/api/category.js @@ -125,6 +125,21 @@ export const category = { }, // #endregion + /** + * POST /api/category/:id/image/generate + * Body (optional): { size?: 2 | 3 | 4 } + * → 200 { category: { id, fullHash, thumbW, thumbH, thumbHash, size, grid, articleCount } } + */ + // #region POST /api/category/:id/image/generate — generateImage + generateImage(categoryId, { size } = {}) { + return http(`/api/category/${categoryId}/image/generate`, { + method: 'POST', + body: size != null ? { size } : undefined, + }); + }, + // #endregion + + /** * GET /api/category/export.zip → application/zip (binary download) * `kategorien.zip` = kategorien.csv (semicolon-separated, Windows-1252, diff --git a/src/automats/categoryAutomat.js b/src/automats/categoryAutomat.js index b65e6b0..1c9ce68 100644 --- a/src/automats/categoryAutomat.js +++ b/src/automats/categoryAutomat.js @@ -401,6 +401,28 @@ const actions = { return res.category; }, + /** + * Generate a mosaic image for a category from its articles and subcategories. + * @param {{ categoryId: number|string, size?: number }} param0 + */ + async generateCategoryImage({ categoryId, size } = {}) { + const res = await categoryApi.generateImage(categoryId, { size }); + const { id, fullHash, thumbW, thumbH, thumbHash } = res.category; + const { categories } = categoryAutomat.state; + if (!categories) return res.category; + const key = String(id); + const nextCategories = { + ...categories, + [key]: { ...categories[key], fullHash, thumbW, thumbH, thumbHash }, + }; + categoryAutomat.setState({ + categories: nextCategories, + }); + refreshCategoryViewChildren(nextCategories); + return res.category; + }, + + // ─── Local state patches ─────────────────────────────────────────────────── /** Remove an article from all live category view lists (used after article deletion). */ diff --git a/src/components/tiles/CategoryTile.jsx b/src/components/tiles/CategoryTile.jsx index d111bc8..1056ac2 100644 --- a/src/components/tiles/CategoryTile.jsx +++ b/src/components/tiles/CategoryTile.jsx @@ -3,6 +3,8 @@ import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; import IconButton from '@mui/material/IconButton'; import Switch from '@mui/material/Switch'; +import CircularProgress from '@mui/material/CircularProgress'; +import GridViewIcon from '@mui/icons-material/GridView'; import DragHandleIcon from '@mui/icons-material/DragHandle'; import AccountTreeIcon from '@mui/icons-material/AccountTree'; import SettingsIcon from '@mui/icons-material/Settings'; @@ -116,7 +118,17 @@ function TopBand({ onSettingsClick, settingsTab, terminalCount, onPlacementClick /** * Middle row (1/3): gray band — TextField or terminal toggle. */ -function MiddleBand({ settingsTab, nameInput, terminals, categoryPosterminalIds, onTerminalToggle, fullHash }) { +function MiddleBand({ + settingsTab, + nameInput, + terminals, + categoryPosterminalIds, + onTerminalToggle, + fullHash, + onPhotoUpload, + onPhotoMatrix, + generatingMatrix, +}) { const isActionTab = settingsTab === -2 || settingsTab === -3 || settingsTab === -4; const fillSx = { width: '100%', @@ -162,22 +174,131 @@ function MiddleBand({ settingsTab, nameInput, terminals, categoryPosterminalIds, // Show photo upload UI when on photo tab if (settingsTab === -2) { + if (fullHash) { + return ( + + + + + Bild löschen + + + + ); + } + return ( - - - + + { + e.stopPropagation(); + onPhotoUpload?.(); + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + onPhotoUpload?.(); + } + }} + sx={{ + flex: 1, + height: '100%', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + gap: 0.25, + cursor: 'pointer', + borderRight: '1px solid', + borderColor: 'grey.400', + userSelect: 'none', + transition: 'background-color 0.15s', + '&:hover': { bgcolor: 'grey.400' }, + '&:active': { bgcolor: 'grey.500' }, + }} + > + - {fullHash ? 'Bild löschen' : 'Hochladen'} + Hochladen + + + { + e.stopPropagation(); + onPhotoMatrix?.(); + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + onPhotoMatrix?.(); + } + }} + sx={{ + flex: 1, + height: '100%', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + gap: 0.25, + cursor: generatingMatrix ? 'wait' : 'pointer', + userSelect: 'none', + transition: 'background-color 0.15s', + '&:hover': { bgcolor: generatingMatrix ? undefined : 'grey.400' }, + '&:active': { bgcolor: generatingMatrix ? undefined : 'grey.500' }, + }} + > + {generatingMatrix ? ( + + ) : ( + + )} + + Matrix ); } + // Show category deletion UI when on delete tab if (settingsTab === -3) { return ( @@ -303,7 +424,15 @@ class CategoryBackdrop extends PureComponent { * lower half = gray band with chevrons + delete */ export class CategoryTile extends Component { - state = { settingsTab: -1 }; // -1 = rename, -2 = photo, -3 = delete, -4 = delete recursive, 0..n-1 = terminal index + state = { settingsTab: -1, generatingMatrix: false }; // -1 = rename, -2 = photo, -3 = delete, -4 = delete recursive, 0..n-1 = terminal index + + componentDidMount() { + this._isMounted = true; + } + + componentWillUnmount() { + this._isMounted = false; + } handleClick = () => { this.props.onClick?.(); @@ -338,14 +467,27 @@ export class CategoryTile extends Component { }); }; + handlePhotoMatrix = async (e) => { + e?.stopPropagation?.(); + if (this.state.generatingMatrix) return; + const fn = this.props.onPhotoMatrix || this.props.onPhotoGenerate; + if (!fn) return; + this.setState({ generatingMatrix: true }); + try { + await fn(); + } finally { + if (this._isMounted) { + this.setState({ generatingMatrix: false }); + } + } + }; + handleMiddleBandClick = () => { const { settingsTab } = this.state; - const { onPhotoUpload, onPhotoDelete, fullHash, onDelete, onDeleteRecursive } = this.props; + const { onPhotoDelete, fullHash, onDelete, onDeleteRecursive } = this.props; if (settingsTab === -2) { if (fullHash) { onPhotoDelete?.(); - } else { - onPhotoUpload?.(); } } else if (settingsTab === -3) { onDelete?.(); @@ -400,13 +542,15 @@ export class CategoryTile extends Component { onTerminalToggle, onPhotoUpload, onPhotoDelete, + onPhotoMatrix, + onPhotoGenerate, onPlacementClick, onOpen, onDelete, onDeleteRecursive, ...rest } = this.props; - const { settingsTab } = this.state; + const { settingsTab, generatingMatrix } = this.state; // Shared by both modes: the thumbnail backdrop. const backdrop = ( @@ -431,7 +575,7 @@ export class CategoryTile extends Component { left: 0, right: 0, cursor: - settingsTab === -2 || settingsTab === -3 || settingsTab === -4 + (settingsTab === -2 && fullHash) || settingsTab === -3 || settingsTab === -4 ? 'pointer' : 'default', }} @@ -443,6 +587,9 @@ export class CategoryTile extends Component { categoryPosterminalIds={categoryPosterminalIds} onTerminalToggle={onTerminalToggle} fullHash={fullHash} + onPhotoUpload={onPhotoUpload} + onPhotoMatrix={this.handlePhotoMatrix} + generatingMatrix={generatingMatrix} /> {/* Lower third: clickable in edit mode to open/navigate into category */} diff --git a/src/routes/artikel/kategorien.jsx b/src/routes/artikel/kategorien.jsx index 5646184..d8df12a 100644 --- a/src/routes/artikel/kategorien.jsx +++ b/src/routes/artikel/kategorien.jsx @@ -363,6 +363,15 @@ class ArtikelKategorienPage extends PureComponent { } }; + handleGenerateCategoryMatrix = async (cat) => { + try { + await categoryAutomat.actions.generateCategoryImage({ categoryId: cat.id }); + enqueueSnackbar('Matrix-Bild erstellt.', { variant: 'success' }); + } catch (err) { + notifyError(err); + } + }; + render() { const { companyId, @@ -500,6 +509,7 @@ class ArtikelKategorienPage extends PureComponent { void categoryAutomat.actions.deleteCategoryImage(item.id) .catch(notifyError); }, + onPhotoMatrix: () => this.handleGenerateCategoryMatrix(item), onDelete: () => this.handleDeleteCategory(item), onDeleteRecursive: () => this.handleDeleteCategoryRecursive(item), nameInput: (