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: (