u
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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). */
|
||||||
|
|||||||
@@ -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,6 +174,7 @@ 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 (
|
return (
|
||||||
<Box sx={fillSx}>
|
<Box sx={fillSx}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
@@ -171,13 +184,121 @@ function MiddleBand({ settingsTab, nameInput, terminals, categoryPosterminalIds,
|
|||||||
noWrap
|
noWrap
|
||||||
sx={{ maxWidth: 100, color: 'text.secondary' }}
|
sx={{ maxWidth: 100, color: 'text.secondary' }}
|
||||||
>
|
>
|
||||||
{fullHash ? 'Bild löschen' : 'Hochladen'}
|
Bild löschen
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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={{
|
||||||
|
fontSize: '0.68rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'text.secondary',
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
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
|
// 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 */}
|
||||||
|
|||||||
@@ -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: (
|
||||||
|
|||||||
Reference in New Issue
Block a user