Compare commits

..

5 Commits

Author SHA1 Message Date
seb
13e1821355 u 2026-10-25 17:56:12 +01:00
seb
eeb4b92a79 u 2026-10-24 06:43:56 +02:00
seb
3801ac4e52 u 2026-10-24 06:20:51 +02:00
seb
31fdc30c5a u 2026-10-24 01:47:14 +02:00
seb
bf39f4d017 u 2026-10-24 01:39:31 +02:00
6 changed files with 273 additions and 38 deletions

View File

@@ -131,7 +131,7 @@ Strip supplierName?: str from T11 (resolve via entityId in cached supplier list)
# Dash API Routes Overview (All)
Overview of all 268 API endpoints across all modules.
Overview of all 270 API endpoints across all modules.
### Notation
@@ -460,6 +460,9 @@ _none_
#### Add category image — `POST /category/{id}/image`
- Params: `id: int, image/avif`
- Returns: `201 { category: { id: int, thumbW: int, thumbH: int, thumbHash: str } }`
#### Generate category mosaic image from contents — `POST /category/{id}/image/generate`
- Params: `id: int, body?: { size?: int }`
- Returns: `200 { category: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, size: int, grid: str, articleCount: int } }`
#### List category properties — `GET /category/{id}/property`
- Params: `id: int`
- Returns: `200 { properties: { id: int, propertyId: int, name: str, exclusive: bool, value: str, sort: int, categoryId?: int }[] }`
@@ -1055,6 +1058,9 @@ _none_
#### JTL-POS category synchronization — `GET /v1/category`
- Params: `query: lastChangedCategory?: str, limit?: int`
- Returns: `200 {}[]`
#### POS category image delivery — `GET /v1/cimage`
- Params: `query: path: str, size?: int, w?: int, h?: int, format?: str`
- Returns: `200 jpeg`
#### POS discovery and pairing — `GET /v1/client`
- Params: `query: authCode: str, name?: str`
- Returns: `200 { authCode: str|null, authToken: str|null, certificateFingerprint: str, certificateSerialNumber: str, mandantId: str, mandantName: str|null, mandantDatabase: str|null, serverFingerprint: str|null, name: str|null, serverTimestamp: str }`
@@ -1074,7 +1080,7 @@ _none_
- Params: `query: mandantId?: str, body: { orders?: {}[] }`
- Returns: `200 { status: str, externalOrderId: str, message: str }[]`
#### POS product image delivery — `GET /v1/pimage`
- Params: `query: path: str, w?: int, h?: int, format?: str`
- Params: `query: path: str, size?: int, w?: int, h?: int, format?: str`
- Returns: `200 jpeg`
#### JTL-POS product catalog synchronization — `GET /v1/product`
- Params: `query: lastChangedProduct?: str, limit?: int`

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

@@ -4,7 +4,6 @@ import Button from '@mui/material/Button';
import Chip from '@mui/material/Chip';
import CircularProgress from '@mui/material/CircularProgress';
import Link from '@mui/material/Link';
import ListItemButton from '@mui/material/ListItemButton';
import Skeleton from '@mui/material/Skeleton';
import Typography from '@mui/material/Typography';
import ViewModuleIcon from '@mui/icons-material/ViewModule';
@@ -125,13 +124,23 @@ class ArticleHistoryRow extends PureComponent {
renderLink(label, onClick) {
return (
<Link
component="button"
type="button"
component="span"
role="button"
tabIndex={0}
variant="body2"
onClick={(e) => {
e.stopPropagation();
const selection = window.getSelection()?.toString();
if (selection && selection.length > 0) return;
onClick?.(e);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
onClick?.(e);
}
}}
sx={{
fontWeight: 600,
verticalAlign: 'baseline',
@@ -141,6 +150,7 @@ class ArticleHistoryRow extends PureComponent {
textDecoration: 'underline',
fontFamily: 'inherit',
fontSize: 'inherit',
userSelect: 'text',
p: 0,
m: 0,
border: 'none',
@@ -321,6 +331,7 @@ class ArticleHistoryRow extends PureComponent {
width: 14,
height: 14,
lineHeight: 0,
cursor: 'default',
m: 0,
my: 0,
p: 0,
@@ -347,9 +358,7 @@ class ArticleHistoryRow extends PureComponent {
if (!item) {
if (disabled) {
return (
<ListItemButton
disabled
divider
<Box
sx={{
...rowGridSx,
height: 32,
@@ -357,19 +366,28 @@ class ArticleHistoryRow extends PureComponent {
maxHeight: 32,
px: 1.5,
py: 0,
borderBottom: '1px solid',
borderColor: 'divider',
opacity: 0,
pointerEvents: 'none',
}}
>
<Typography variant="body2" sx={{ fontSize: '0.8125rem' }}>&nbsp;</Typography>
</ListItemButton>
</Box>
);
}
return (
<ListItemButton
disabled
divider
sx={{ ...rowGridSx, height: 32, minHeight: 32, maxHeight: 32, px: 1.5, py: 0 }}
<Box
sx={{
...rowGridSx,
height: 32,
minHeight: 32,
maxHeight: 32,
px: 1.5,
py: 0,
borderBottom: '1px solid',
borderColor: 'divider',
}}
>
{renderInvisibleDummyRow(dummy)}
<Box sx={{ gridColumn: 1, gridRow: 2 }}>
@@ -378,25 +396,37 @@ class ArticleHistoryRow extends PureComponent {
<Box sx={{ gridColumn: 2, gridRow: 2 }}>
<Skeleton variant="text" width="95%" height={16} />
</Box>
</ListItemButton>
</Box>
);
}
const meta = decodeMeta(item.meta);
return (
<ListItemButton
divider
disabled={disabled}
sx={{ ...rowGridSx, height: 32, minHeight: 32, maxHeight: 32, px: 1.5, py: 0 }}
<Box
sx={{
...rowGridSx,
height: 32,
minHeight: 32,
maxHeight: 32,
px: 1.5,
py: 0,
borderBottom: '1px solid',
borderColor: 'divider',
userSelect: 'text',
cursor: 'text',
'&:hover': {
bgcolor: 'action.hover',
},
}}
>
{renderInvisibleDummyRow(dummy)}
<Box sx={{ gridColumn: 1, gridRow: 2, minWidth: 0 }}>
<Box sx={{ gridColumn: 1, gridRow: 2, minWidth: 0, userSelect: 'text', cursor: 'text' }}>
<Typography
variant="body2"
color="text.secondary"
sx={{ fontVariantNumeric: 'tabular-nums', fontSize: '0.8125rem', ...ellipsisSx }}
sx={{ fontVariantNumeric: 'tabular-nums', fontSize: '0.8125rem', userSelect: 'text', cursor: 'text', ...ellipsisSx }}
>
{formatDateTime(item.date || item.createdAt)}
</Typography>
@@ -410,18 +440,20 @@ class ArticleHistoryRow extends PureComponent {
display: 'flex',
alignItems: 'center',
overflow: 'hidden',
userSelect: 'text',
cursor: 'text',
}}
>
{this.renderSkelehashThumb(meta)}
<Typography
variant="body2"
component="div"
sx={{ ...ellipsisSx, display: 'inline', width: '100%', fontSize: '0.8125rem' }}
sx={{ ...ellipsisSx, display: 'inline', width: '100%', fontSize: '0.8125rem', userSelect: 'text', cursor: 'text' }}
>
{this.renderEntrySpans(item.text, meta)}
</Typography>
</Box>
</ListItemButton>
</Box>
);
}
}
@@ -836,8 +868,11 @@ class ArticleHistoryTab extends PureComponent {
handleOpenBrand(brandName) {
if (!brandName) return;
brandAutomat.actions.filter(brandName);
this.props.navigate('/artikel/marken');
const match = (brandAutomat.state.brands || []).find(
(b) => b && (b.name?.toLowerCase() === brandName.toLowerCase() || String(b.id) === String(brandName)),
);
const brandParam = match?.id != null ? String(match.id) : brandName;
this.props.navigate(`/artikel?brand=${encodeURIComponent(brandParam)}`);
}
render() {
@@ -865,8 +900,8 @@ class ArticleHistoryTab extends PureComponent {
<Box sx={{ width: '100%', position: 'relative' }}>
<FillSpace
aboveRef={[this.searchRef]}
reserveBottom={16}
minPageSize={20}
reserveBottom={48}
minPageSize={8}
minHeight={0}
resetKey={filterKey}
items={items || []}

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