Compare commits
5 Commits
2f946442c8
...
13e1821355
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13e1821355 | ||
|
|
eeb4b92a79 | ||
|
|
3801ac4e52 | ||
|
|
31fdc30c5a | ||
|
|
bf39f4d017 |
@@ -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`
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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' }}> </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 || []}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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: (
|
||||
|
||||
Reference in New Issue
Block a user