This commit is contained in:
seb
2026-10-24 06:43:56 +02:00
parent 3801ac4e52
commit eeb4b92a79
4 changed files with 206 additions and 12 deletions

View File

@@ -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,

View File

@@ -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). */

View File

@@ -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 (
<Box sx={fillSx}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<PhotoCameraIcon sx={{ fontSize: 20, color: 'text.secondary' }} />
<Typography
variant="caption"
noWrap
sx={{ maxWidth: 100, color: 'text.secondary' }}
>
Bild löschen
</Typography>
</Box>
</Box>
);
}
return (
<Box sx={fillSx}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<PhotoCameraIcon sx={{ fontSize: 20, color: 'text.secondary' }} />
<Box
sx={{
width: '100%',
height: '100%',
display: 'flex',
bgcolor: 'grey.300',
}}
>
<Box
role="button"
tabIndex={0}
aria-label="Hochladen"
onClick={(e) => {
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' },
}}
>
<PhotoCameraIcon sx={{ fontSize: 18, color: 'text.secondary' }} />
<Typography
variant="caption"
noWrap
sx={{ maxWidth: 100, color: 'text.secondary' }}
sx={{
fontSize: '0.68rem',
fontWeight: 600,
color: 'text.secondary',
lineHeight: 1,
}}
>
{fullHash ? 'Bild löschen' : 'Hochladen'}
Hochladen
</Typography>
</Box>
<Box
role="button"
tabIndex={0}
aria-label="Matrix"
onClick={(e) => {
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 ? (
<CircularProgress size={16} sx={{ color: 'text.secondary' }} />
) : (
<GridViewIcon sx={{ fontSize: 18, color: 'text.secondary' }} />
)}
<Typography
variant="caption"
noWrap
sx={{
fontSize: '0.68rem',
fontWeight: 600,
color: 'text.secondary',
lineHeight: 1,
}}
>
Matrix
</Typography>
</Box>
</Box>
);
}
// 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}
/>
</Box>
{/* Lower third: clickable in edit mode to open/navigate into category */}

View File

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