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 // #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) * GET /api/category/export.zip → application/zip (binary download)
* `kategorien.zip` = kategorien.csv (semicolon-separated, Windows-1252, * `kategorien.zip` = kategorien.csv (semicolon-separated, Windows-1252,

View File

@@ -401,6 +401,28 @@ const actions = {
return res.category; 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 ─────────────────────────────────────────────────── // ─── Local state patches ───────────────────────────────────────────────────
/** Remove an article from all live category view lists (used after article deletion). */ /** 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 Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton'; import IconButton from '@mui/material/IconButton';
import Switch from '@mui/material/Switch'; 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 DragHandleIcon from '@mui/icons-material/DragHandle';
import AccountTreeIcon from '@mui/icons-material/AccountTree'; import AccountTreeIcon from '@mui/icons-material/AccountTree';
import SettingsIcon from '@mui/icons-material/Settings'; 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. * 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 isActionTab = settingsTab === -2 || settingsTab === -3 || settingsTab === -4;
const fillSx = { const fillSx = {
width: '100%', width: '100%',
@@ -162,22 +174,131 @@ function MiddleBand({ settingsTab, nameInput, terminals, categoryPosterminalIds,
// Show photo upload UI when on photo tab // Show photo upload UI when on photo tab
if (settingsTab === -2) { 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 ( return (
<Box sx={fillSx}> <Box
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> sx={{
<PhotoCameraIcon sx={{ fontSize: 20, color: 'text.secondary' }} /> 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 <Typography
variant="caption" variant="caption"
noWrap 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> </Typography>
</Box> </Box>
</Box> </Box>
); );
} }
// Show category deletion UI when on delete tab // Show category deletion UI when on delete tab
if (settingsTab === -3) { if (settingsTab === -3) {
return ( return (
@@ -303,7 +424,15 @@ class CategoryBackdrop extends PureComponent {
* lower half = gray band with chevrons + delete * lower half = gray band with chevrons + delete
*/ */
export class CategoryTile extends Component { 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 = () => { handleClick = () => {
this.props.onClick?.(); 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 = () => { handleMiddleBandClick = () => {
const { settingsTab } = this.state; const { settingsTab } = this.state;
const { onPhotoUpload, onPhotoDelete, fullHash, onDelete, onDeleteRecursive } = this.props; const { onPhotoDelete, fullHash, onDelete, onDeleteRecursive } = this.props;
if (settingsTab === -2) { if (settingsTab === -2) {
if (fullHash) { if (fullHash) {
onPhotoDelete?.(); onPhotoDelete?.();
} else {
onPhotoUpload?.();
} }
} else if (settingsTab === -3) { } else if (settingsTab === -3) {
onDelete?.(); onDelete?.();
@@ -400,13 +542,15 @@ export class CategoryTile extends Component {
onTerminalToggle, onTerminalToggle,
onPhotoUpload, onPhotoUpload,
onPhotoDelete, onPhotoDelete,
onPhotoMatrix,
onPhotoGenerate,
onPlacementClick, onPlacementClick,
onOpen, onOpen,
onDelete, onDelete,
onDeleteRecursive, onDeleteRecursive,
...rest ...rest
} = this.props; } = this.props;
const { settingsTab } = this.state; const { settingsTab, generatingMatrix } = this.state;
// Shared by both modes: the thumbnail backdrop. // Shared by both modes: the thumbnail backdrop.
const backdrop = ( const backdrop = (
@@ -431,7 +575,7 @@ export class CategoryTile extends Component {
left: 0, left: 0,
right: 0, right: 0,
cursor: cursor:
settingsTab === -2 || settingsTab === -3 || settingsTab === -4 (settingsTab === -2 && fullHash) || settingsTab === -3 || settingsTab === -4
? 'pointer' ? 'pointer'
: 'default', : 'default',
}} }}
@@ -443,6 +587,9 @@ export class CategoryTile extends Component {
categoryPosterminalIds={categoryPosterminalIds} categoryPosterminalIds={categoryPosterminalIds}
onTerminalToggle={onTerminalToggle} onTerminalToggle={onTerminalToggle}
fullHash={fullHash} fullHash={fullHash}
onPhotoUpload={onPhotoUpload}
onPhotoMatrix={this.handlePhotoMatrix}
generatingMatrix={generatingMatrix}
/> />
</Box> </Box>
{/* Lower third: clickable in edit mode to open/navigate into category */} {/* 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() { render() {
const { const {
companyId, companyId,
@@ -500,6 +509,7 @@ class ArtikelKategorienPage extends PureComponent {
void categoryAutomat.actions.deleteCategoryImage(item.id) void categoryAutomat.actions.deleteCategoryImage(item.id)
.catch(notifyError); .catch(notifyError);
}, },
onPhotoMatrix: () => this.handleGenerateCategoryMatrix(item),
onDelete: () => this.handleDeleteCategory(item), onDelete: () => this.handleDeleteCategory(item),
onDeleteRecursive: () => this.handleDeleteCategoryRecursive(item), onDeleteRecursive: () => this.handleDeleteCategoryRecursive(item),
nameInput: ( nameInput: (