This commit is contained in:
seb
2026-08-10 03:13:53 +02:00
parent 7a1c6e2d94
commit 8bbd6fade1
8 changed files with 682 additions and 172 deletions

View File

@@ -1,13 +1,211 @@
import NameDialog from '../NameDialog';
import { Component } from 'react';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Dialog from '@mui/material/Dialog';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
import Alert from '@mui/material/Alert';
import BackendError from '../BackendError';
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
import { backendErrorFrom, isBackendFailure } from '../../utils/backendError';
export default function ArticleNameDialog({ open, onClose, onSubmit }) {
return (
<NameDialog
open={open}
title="Neuer Artikel"
submitLabel="Anlegen"
onClose={onClose}
onSubmit={onSubmit}
/>
);
const BULK_MODE = 'bulk';
const SINGLE_MODE = 'single';
/** Split a bulk textarea into non-empty, trimmed names (one per line). */
function parseBulkNames(text) {
return String(text ?? '')
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
}
/**
* New-article dialog.
* Single mode: one name. Bulk mode ("Sammelanlegen"): one name per line.
* Props: open, onClose, onSubmit({ names: string[] }).
*/
class ArticleNameDialog extends Component {
constructor(props) {
super(props);
this.state = {
mode: SINGLE_MODE,
name: '',
bulkText: '',
busy: false,
error: null,
};
this.handleClose = this.handleClose.bind(this);
this.handleSingleSubmit = this.handleSingleSubmit.bind(this);
this.handleBulkSubmit = this.handleBulkSubmit.bind(this);
this.openBulk = this.openBulk.bind(this);
}
componentDidUpdate(prevProps) {
if (this.props.open && !prevProps.open) {
this.setState({
mode: SINGLE_MODE,
name: '',
bulkText: '',
busy: false,
error: null,
});
}
}
handleClose() {
if (this.state.busy) return;
this.props.onClose();
}
async handleSingleSubmit(event) {
event.preventDefault();
const name = this.state.name.trim();
if (!name) {
this.setState({ error: 'Bitte Name eingeben.' });
return;
}
this.setState({ busy: true, error: null });
try {
await this.props.onSubmit({ names: [name] });
this.props.onClose();
} catch (err) {
this.setState({ error: backendErrorFrom(err), busy: false });
}
}
async handleBulkSubmit(event) {
event.preventDefault();
const names = parseBulkNames(this.state.bulkText);
if (names.length === 0) {
this.setState({ error: 'Bitte mindestens einen Namen eingeben.' });
return;
}
this.setState({ busy: true, error: null });
try {
await this.props.onSubmit({ names });
this.props.onClose();
} catch (err) {
this.setState({ error: backendErrorFrom(err), busy: false });
}
}
openBulk() {
if (this.state.busy) return;
this.setState({ mode: BULK_MODE, error: null });
}
render() {
const { open } = this.props;
const { mode, name, bulkText, busy, error } = this.state;
const isBulk = mode === BULK_MODE;
const canSubmitSingle = name.trim() !== '';
const bulkNames = parseBulkNames(bulkText);
const canSubmitBulk = bulkNames.length > 0;
return (
<Dialog open={open} onClose={this.handleClose} maxWidth="xs" fullWidth>
<DialogTitle>Neuer Artikel</DialogTitle>
{!isBulk && (
<Box component="form" onSubmit={this.handleSingleSubmit} noValidate>
<DialogContent>
{error ? (
isBackendFailure(error) ? (
<BackendError error={error} sx={{ mb: 2 }} />
) : (
<Alert severity="error" sx={{ mb: 2 }}>
{typeof error === 'string' ? error : error.message}
</Alert>
)
) : null}
<SingleLineTextEdit
required
autoFocus
label="Name"
value={name}
disabled={busy}
onChange={(nextName) => this.setState({ name: nextName })}
/>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button type="button" onClick={this.openBulk} disabled={busy}>
Sammelanlegen
</Button>
<Box sx={{ flexGrow: 1 }} />
<Button type="button" onClick={this.handleClose} disabled={busy}>
Abbrechen
</Button>
<Button
type="submit"
variant="contained"
disabled={busy || !canSubmitSingle}
>
Anlegen
</Button>
</DialogActions>
</Box>
)}
{isBulk && (
<Box component="form" onSubmit={this.handleBulkSubmit} noValidate>
<DialogContent>
{error ? (
isBackendFailure(error) ? (
<BackendError error={error} sx={{ mb: 2 }} />
) : (
<Alert severity="error" sx={{ mb: 2 }}>
{typeof error === 'string' ? error : error.message}
</Alert>
)
) : null}
<SingleLineTextEdit
autoFocus
multiline
minRows={6}
label="Namen (einer pro Zeile)"
placeholder={'Artikel A\nArtikel B\nArtikel C'}
value={bulkText}
disabled={busy}
onChange={(nextText) => this.setState({ bulkText: nextText })}
helperText={
bulkNames.length > 0
? `${bulkNames.length} Artikel werden angelegt.`
: 'Ein Name pro Zeile eingeben.'
}
/>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button
type="button"
onClick={() => this.setState({ mode: SINGLE_MODE, error: null })}
disabled={busy}
>
Einzel
</Button>
<Box sx={{ flexGrow: 1 }} />
<Button type="button" onClick={this.handleClose} disabled={busy}>
Abbrechen
</Button>
<Button
type="submit"
variant="contained"
disabled={busy || !canSubmitBulk}
>
Anlegen ({bulkNames.length})
</Button>
</DialogActions>
</Box>
)}
</Dialog>
);
}
}
export default ArticleNameDialog;

View File

@@ -1,7 +1,10 @@
import { Component } from 'react';
import { connect } from 'react-redux';
import { ActionTile } from '../tiles';
import { createArticle as createArticleThunk } from '../../store/categorySlice';
import {
createArticles as createArticlesThunk,
createArticle as createArticleThunk,
} from '../../store/categorySlice';
import ArticleNameDialog from './ArticleNameDialog';
class NewArticleTile extends Component {
@@ -21,11 +24,21 @@ class NewArticleTile extends Component {
this.setState({ open: false });
}
async handleSubmit({ name }) {
async handleSubmit({ names }) {
const { parentId } = this.props;
const article = await this.props.createArticle({ parentId, name }).unwrap();
if (article) {
this.props.onOpenArticle?.(article);
const list = (names ?? []).filter(Boolean);
if (list.length <= 1) {
const article = await this.props
.createArticle({ parentId, name: list[0] ?? '' })
.unwrap();
if (article) {
this.props.onOpenArticle?.(article);
}
return;
}
const created = await this.props.createArticles({ parentId, names: list }).unwrap();
if (created?.[0]) {
this.props.onOpenArticle?.(created[0]);
}
}
@@ -51,6 +64,7 @@ class NewArticleTile extends Component {
const mapDispatchToProps = {
createArticle: createArticleThunk,
createArticles: createArticlesThunk,
};
export default connect(null, mapDispatchToProps)(NewArticleTile);

View File

@@ -1,4 +1,14 @@
import NameDialog from '../NameDialog';
import { Component } from 'react';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Dialog from '@mui/material/Dialog';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
import Alert from '@mui/material/Alert';
import BackendError from '../BackendError';
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
import { backendErrorFrom, isBackendFailure } from '../../utils/backendError';
const TITLES = {
create: 'Neue Kategorie',
@@ -10,21 +20,206 @@ const SUBMIT_LABELS = {
edit: 'Speichern',
};
export default function CategoryNameDialog({
open,
mode = 'create',
initialName,
onClose,
onSubmit,
}) {
return (
<NameDialog
open={open}
title={TITLES[mode]}
submitLabel={SUBMIT_LABELS[mode]}
initialName={initialName}
onClose={onClose}
onSubmit={onSubmit}
/>
);
const BULK_MODE = 'bulk';
const SINGLE_MODE = 'single';
/** Split a bulk textarea into non-empty, trimmed names (one per line). */
function parseBulkNames(text) {
return String(text ?? '')
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
}
/**
* Category name dialog.
* Single mode: one name (create/edit). Bulk mode ("Sammelanlegen", create
* only): one name per line. Props: open, mode ('create' | 'edit'), initialName,
* onClose, onSubmit({ names: string[] }).
*/
class CategoryNameDialog extends Component {
constructor(props) {
super(props);
this.state = {
mode: SINGLE_MODE,
name: '',
bulkText: '',
busy: false,
error: null,
};
this.handleClose = this.handleClose.bind(this);
this.handleSingleSubmit = this.handleSingleSubmit.bind(this);
this.handleBulkSubmit = this.handleBulkSubmit.bind(this);
this.openBulk = this.openBulk.bind(this);
}
componentDidUpdate(prevProps) {
if (this.props.open && !prevProps.open) {
this.setState({
mode: SINGLE_MODE,
name: this.props.initialName ?? '',
bulkText: '',
busy: false,
error: null,
});
}
}
handleClose() {
if (this.state.busy) return;
this.props.onClose();
}
async handleSingleSubmit(event) {
event.preventDefault();
const name = this.state.name.trim();
if (!name) {
this.setState({ error: 'Bitte Name eingeben.' });
return;
}
this.setState({ busy: true, error: null });
try {
await this.props.onSubmit({ names: [name] });
this.props.onClose();
} catch (err) {
this.setState({ error: backendErrorFrom(err), busy: false });
}
}
async handleBulkSubmit(event) {
event.preventDefault();
const names = parseBulkNames(this.state.bulkText);
if (names.length === 0) {
this.setState({ error: 'Bitte mindestens einen Namen eingeben.' });
return;
}
this.setState({ busy: true, error: null });
try {
await this.props.onSubmit({ names });
this.props.onClose();
} catch (err) {
this.setState({ error: backendErrorFrom(err), busy: false });
}
}
openBulk() {
if (this.state.busy) return;
this.setState({ mode: BULK_MODE, error: null });
}
render() {
const { open, mode = 'create' } = this.props;
const { mode: formMode, name, bulkText, busy, error } = this.state;
const isBulk = formMode === BULK_MODE;
const canBulk = mode === 'create';
const canSubmitSingle = name.trim() !== '';
const bulkNames = parseBulkNames(bulkText);
const canSubmitBulk = bulkNames.length > 0;
return (
<Dialog open={open} onClose={this.handleClose} maxWidth="xs" fullWidth>
<DialogTitle>{TITLES[mode]}</DialogTitle>
{!isBulk && (
<Box component="form" onSubmit={this.handleSingleSubmit} noValidate>
<DialogContent>
{error ? (
isBackendFailure(error) ? (
<BackendError error={error} sx={{ mb: 2 }} />
) : (
<Alert severity="error" sx={{ mb: 2 }}>
{typeof error === 'string' ? error : error.message}
</Alert>
)
) : null}
<SingleLineTextEdit
required
autoFocus
label="Name"
value={name}
disabled={busy}
onChange={(nextName) => this.setState({ name: nextName })}
/>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
{canBulk && (
<Button type="button" onClick={this.openBulk} disabled={busy}>
Sammelanlegen
</Button>
)}
<Box sx={{ flexGrow: 1 }} />
<Button type="button" onClick={this.handleClose} disabled={busy}>
Abbrechen
</Button>
<Button
type="submit"
variant="contained"
disabled={busy || !canSubmitSingle}
>
{SUBMIT_LABELS[mode]}
</Button>
</DialogActions>
</Box>
)}
{isBulk && (
<Box component="form" onSubmit={this.handleBulkSubmit} noValidate>
<DialogContent>
{error ? (
isBackendFailure(error) ? (
<BackendError error={error} sx={{ mb: 2 }} />
) : (
<Alert severity="error" sx={{ mb: 2 }}>
{typeof error === 'string' ? error : error.message}
</Alert>
)
) : null}
<SingleLineTextEdit
autoFocus
multiline
minRows={6}
label="Namen (einer pro Zeile)"
placeholder={'Kategorie A\nKategorie B\nKategorie C'}
value={bulkText}
disabled={busy}
onChange={(nextText) => this.setState({ bulkText: nextText })}
helperText={
bulkNames.length > 0
? `${bulkNames.length} Kategorien werden angelegt.`
: 'Ein Name pro Zeile eingeben.'
}
/>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button
type="button"
onClick={() => this.setState({ mode: SINGLE_MODE, error: null })}
disabled={busy}
>
Einzel
</Button>
<Box sx={{ flexGrow: 1 }} />
<Button type="button" onClick={this.handleClose} disabled={busy}>
Abbrechen
</Button>
<Button
type="submit"
variant="contained"
disabled={busy || !canSubmitBulk}
>
Anlegen ({bulkNames.length})
</Button>
</DialogActions>
</Box>
)}
</Dialog>
);
}
}
export default CategoryNameDialog;

View File

@@ -1,7 +1,10 @@
import { Component } from 'react';
import { connect } from 'react-redux';
import { ActionTile } from '../tiles';
import { createCategory as createCategoryThunk } from '../../store/categorySlice';
import {
createCategories as createCategoriesThunk,
createCategory as createCategoryThunk,
} from '../../store/categorySlice';
import CategoryNameDialog from './CategoryNameDialog';
class NewCategoryTile extends Component {
@@ -21,9 +24,14 @@ class NewCategoryTile extends Component {
this.setState({ open: false });
}
async handleSubmit({ name }) {
async handleSubmit({ names }) {
const { parentId } = this.props;
await this.props.createCategory({ parentId, name }).unwrap();
const list = (names ?? []).filter(Boolean);
if (list.length <= 1) {
await this.props.createCategory({ parentId, name: list[0] ?? '' }).unwrap();
return;
}
await this.props.createCategories({ parentId, names: list }).unwrap();
}
render() {
@@ -49,6 +57,7 @@ class NewCategoryTile extends Component {
const mapDispatchToProps = {
createCategory: createCategoryThunk,
createCategories: createCategoriesThunk,
};
export default connect(null, mapDispatchToProps)(NewCategoryTile);

View File

@@ -1,4 +1,4 @@
import { Component } from 'react';
import { Component, PureComponent } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton';
@@ -74,7 +74,7 @@ function TopBand({ onSettingsClick, settingsTab, terminalCount }) {
/**
* Middle row (1/3): gray band — TextField or terminal toggle.
*/
function MiddleBand({ settingsTab, nameInput, terminals, categoryPosterminalIds, onTerminalToggle, onPhotoUpload, onPhotoDelete, categoryFullHash }) {
function MiddleBand({ settingsTab, nameInput, terminals, categoryPosterminalIds, onTerminalToggle, onPhotoUpload, onPhotoDelete, fullHash }) {
const fillSx = {
width: '100%',
height: '100%',
@@ -121,7 +121,7 @@ function MiddleBand({ settingsTab, nameInput, terminals, categoryPosterminalIds,
noWrap
sx={{ maxWidth: 80, color: 'text.secondary' }}
>
{categoryFullHash ? 'Löschen' : 'Hochladen'}
{fullHash ? 'Löschen' : 'Hochladen'}
</Typography>
</Box>
</Box>
@@ -217,7 +217,7 @@ function BottomRow({ onOpen, editBand }) {
* Renders a ThumbHash preview and the full thumbnail as backdrop for a category tile.
* ThumbHash is shown immediately as a placeholder; the thumb fades in once loaded.
*/
class CategoryBackdrop extends Component {
class CategoryBackdrop extends PureComponent {
state = { thumbLoaded: false };
componentDidUpdate(prevProps) {
@@ -265,13 +265,52 @@ class CategoryBackdrop extends Component {
}
/**
* Category tile (normal mode): no icon, click navigates to the category.
* Category tile.
*
* Normal mode (edit=false): clickable tile with label overlay; click navigates.
* Edit mode (edit=true): three rows of 1/3 each —
* Top row: sort-handle | settings toggle | drop-handle
* Middle row: gray band — TextField, POS toggle, or photo upload
* Bottom row: upper half = open-category click zone
* lower half = gray band with chevrons + delete
*/
export class CategoryTile extends Component {
state = { settingsTab: -1 }; // -1 = rename, -2 = photo, 0..n-1 = terminal index
handleClick = () => {
this.props.onClick?.();
};
handleKeyDown = (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
this.handleClick();
}
};
handleSettingsToggle = () => {
const { terminals } = this.props;
const terminalCount = terminals?.length || 0;
this.setState((prev) => {
const next = prev.settingsTab + 1;
// Cycle: photo (-2) → rename (-1) → terminal 0 → ... → terminal n-1 → photo (-2)
const nextTab = next >= terminalCount ? -2 : next;
return { settingsTab: nextTab };
});
};
handleMiddleBandClick = () => {
const { settingsTab } = this.state;
const { onPhotoUpload, onPhotoDelete, fullHash } = this.props;
if (settingsTab === -2) {
if (fullHash) {
onPhotoDelete?.();
} else {
onPhotoUpload?.();
}
}
};
renderLabel() {
const { label, nameInput } = this.props;
if (label == null) return null;
@@ -304,123 +343,85 @@ export class CategoryTile extends Component {
);
}
render() {
const { sx, onClick: _onClick, label, thumbHash, fullHash, ...rest } = this.props;
return (
<Box
component="button"
type="button"
onClick={this.handleClick}
{...rest}
sx={{
...tileBaseSx,
position: 'relative',
overflow: 'hidden',
...sx,
}}
>
<CategoryBackdrop thumbHash={thumbHash} fullHash={fullHash} />
<Box sx={{ position: 'absolute', inset: 0, zIndex: 1 }}>
{this.renderLabel()}
</Box>
</Box>
);
}
}
/**
* Category tile for edit mode: three rows of 1/3 each.
*
* Top row: sort-handle | settings toggle | drop-handle
* Middle row: gray band — TextField, POS toggle, or photo upload
* Bottom row: upper half = open-category click zone
* lower half = gray band with chevrons + delete
*/
export class CategoryEditTile extends Component {
state = { settingsTab: -1 }; // -1 = rename, -2 = photo, 0..n-1 = terminal index
handleSettingsToggle = () => {
const { terminals } = this.props;
const terminalCount = terminals?.length || 0;
this.setState((prev) => {
const next = prev.settingsTab + 1;
// Cycle: photo (-2) → rename (-1) → terminal 0 → ... → terminal n-1 → photo (-2)
const nextTab = next >= terminalCount ? -2 : next;
return { settingsTab: nextTab };
});
};
handleMiddleBandClick = () => {
const { settingsTab } = this.state;
const { onPhotoUpload, onPhotoDelete, categoryFullHash } = this.props;
if (settingsTab === -2) {
if (categoryFullHash) {
onPhotoDelete?.();
} else {
onPhotoUpload?.();
}
}
};
render() {
const {
sx,
edit,
label,
thumbHash,
fullHash,
onClick,
nameInput,
editBand,
onOpen,
terminals,
categoryPosterminalIds,
onTerminalToggle,
hasTerminals: _hasTerminals,
categoryFullHash,
thumbHash,
onPhotoUpload: _onPhotoUpload,
onPhotoDelete: _onPhotoDelete,
sx,
onPhotoUpload,
onPhotoDelete,
...rest
} = this.props;
const { settingsTab } = this.state;
// Shared by both modes: the thumbnail backdrop.
const backdrop = (
<CategoryBackdrop thumbHash={thumbHash} fullHash={fullHash} />
);
// Only the overlay differs between normal and edit mode.
const overlay = edit ? (
<>
<TopBand
onSettingsClick={this.handleSettingsToggle}
settingsTab={settingsTab}
terminalCount={terminals?.length || 0}
/>
<Box
onClick={this.handleMiddleBandClick}
sx={{
position: 'absolute',
top: '33.33%',
bottom: '33.33%',
left: 0,
right: 0,
cursor: settingsTab === -2 ? 'pointer' : 'default',
}}
>
<MiddleBand
settingsTab={settingsTab}
nameInput={nameInput}
terminals={terminals}
categoryPosterminalIds={categoryPosterminalIds}
onTerminalToggle={onTerminalToggle}
fullHash={fullHash}
/>
</Box>
<BottomRow onOpen={onOpen} editBand={editBand} />
</>
) : (
this.renderLabel()
);
return (
<Box
{...rest}
role={edit ? undefined : 'button'}
tabIndex={edit ? undefined : 0}
onClick={edit ? undefined : this.handleClick}
onKeyDown={edit ? undefined : this.handleKeyDown}
sx={{
...tileBaseSx,
cursor: 'default',
position: 'relative',
overflow: 'hidden',
'&:hover': { bgcolor: 'background.paper', boxShadow: 0 },
...(edit
? { cursor: 'default', '&:hover': { bgcolor: 'background.paper', boxShadow: 0 } }
: { cursor: 'pointer', '&:hover': { bgcolor: 'action.hover' } }),
...sx,
}}
>
<CategoryBackdrop thumbHash={thumbHash} fullHash={categoryFullHash} />
{backdrop}
<Box sx={{ position: 'absolute', inset: 0, zIndex: 1 }}>
<TopBand
onSettingsClick={this.handleSettingsToggle}
settingsTab={settingsTab}
terminalCount={terminals?.length || 0}
/>
<Box
onClick={this.handleMiddleBandClick}
sx={{
position: 'absolute',
top: '33.33%',
bottom: '33.33%',
left: 0,
right: 0,
cursor: settingsTab === -2 ? 'pointer' : 'default',
}}
>
<MiddleBand
settingsTab={settingsTab}
nameInput={nameInput}
terminals={terminals}
categoryPosterminalIds={categoryPosterminalIds}
onTerminalToggle={onTerminalToggle}
categoryFullHash={categoryFullHash}
/>
</Box>
<BottomRow onOpen={onOpen} editBand={editBand} />
{overlay}
</Box>
</Box>
);

View File

@@ -1,5 +1,5 @@
export { TILE_SIZE, Tile, tileBaseSx } from 'dashgui/tiles';
export { BackTile } from './BackTile';
export { CategoryTile, CategoryEditTile } from './CategoryTile';
export { CategoryTile } from './CategoryTile';
export { ArticleTile } from './ArticleTile';
export { ActionTile } from './ActionTile';

View File

@@ -14,14 +14,13 @@ import { notifyError } from '../../utils/notify';
import {
ArticleTile,
BackTile,
CategoryEditTile,
CategoryTile,
} from '../../components/tiles';
import { TILE_SIZE } from 'dashgui/tiles';
import withCategoryRoute from './withCategoryRoute';
import { artikelDetailPath, articleSku } from './articleRoute';
import { selectCompanyId } from '../../store/companySlice';
import { selectHasPosterminals, selectPosterminals, loadPosterminals } from '../../store/posterminalSlice';
import { selectPosterminals, loadPosterminals } from '../../store/posterminalSlice';
import {
buildCategoryPath,
defaultCategoryPath,
@@ -281,7 +280,6 @@ class ArtikelKategorienPage extends Component {
childCategories,
articles,
renameMode,
hasTerminals,
terminals,
} = this.props;
@@ -346,20 +344,19 @@ class ArtikelKategorienPage extends Component {
{childCategories.map((item, index) => {
const label = categoryDisplayName(item.name, item.id, rootCategoryId);
if (renameMode) {
const draftName =
this.state.drafts[item.id] !== undefined
? this.state.drafts[item.id]
: label;
return (
<CategoryEditTile
key={item.id}
data-id={String(item.id)}
className="sortable-item"
label={draftName}
terminals={terminals}
categoryPosterminalIds={item.posterminalIds || []}
onTerminalToggle={(terminalId, enabled) => {
const draftName =
this.state.drafts[item.id] !== undefined
? this.state.drafts[item.id]
: label;
const editProps = renameMode
? {
edit: true,
'data-id': String(item.id),
className: 'sortable-item',
label: draftName,
terminals,
categoryPosterminalIds: item.posterminalIds || [],
onTerminalToggle: (terminalId, enabled) => {
const thunk = enabled
? this.props.enableTerminalCategory
: this.props.disableTerminalCategory;
@@ -373,12 +370,9 @@ class ArtikelKategorienPage extends Component {
});
})
.catch(notifyError);
}}
onOpen={() => this.navigateToCategoryById(item.id)}
hasTerminals={hasTerminals}
categoryFullHash={item.fullHash}
thumbHash={item.thumbHash}
onPhotoUpload={() => {
},
onOpen: () => this.navigateToCategoryById(item.id),
onPhotoUpload: () => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
@@ -398,12 +392,12 @@ class ArtikelKategorienPage extends Component {
}
};
input.click();
}}
onPhotoDelete={() => {
},
onPhotoDelete: () => {
void this.props.deleteCategoryImage(item.id)
.catch(notifyError);
}}
nameInput={
},
nameInput: (
<TextField
value={draftName}
onChange={(e) => this.handleDraftChange(item.id, e.target.value)}
@@ -429,24 +423,26 @@ class ArtikelKategorienPage extends Component {
},
}}
/>
}
editBand={{
),
editBand: {
onMoveLeft: () => this.handleMoveCategory(item, -1),
onMoveRight: () => this.handleMoveCategory(item, 1),
onDelete: () => this.handleDeleteCategory(item),
canLeft: index > 0,
canRight: index < childCategories.length - 1,
}}
/>
);
}
},
}
: {
label,
onClick: () => this.navigateToCategoryById(item.id),
};
return (
<CategoryTile
key={item.id}
label={label}
onClick={() => this.navigateToCategoryById(item.id)}
thumbHash={item.thumbHash}
fullHash={item.fullHash}
{...editProps}
/>
);
})}
@@ -493,7 +489,6 @@ const mapStateToProps = (state) => ({
articles: selectCategoryArticles(state),
canGoBack: selectCanNavigateBack(state),
renameMode: selectRenameMode(state),
hasTerminals: selectHasPosterminals(state),
terminals: selectPosterminals(state),
});

View File

@@ -139,6 +139,39 @@ export const createCategory = createAsyncThunk(
},
);
/**
* Create several categories under the same parent in one go (bulk add).
* Runs the single-create API sequentially so every category is created
* even if a later one fails; the first error is reported.
* Returns the array of created (normalized) categories.
*/
export const createCategories = createAsyncThunk(
'category/createCategories',
async ({ parentId, names }, { rejectWithValue }) => {
const list = (names ?? [])
.map((n) => String(n ?? '').trim())
.filter(Boolean);
if (list.length === 0) {
return rejectWithValue({ message: 'Keine Namen übergeben.' });
}
const created = [];
try {
for (const name of list) {
const data = await category.create({ parentId, name });
requireObject(data.category, 'POST /api/category.category');
created.push(normalizeCategoryNode(data.category));
}
return created;
} catch (err) {
const normalized = catchBackendError(rejectWithValue)(err);
// Attach already-created categories so the UI can still update optimistically.
return normalized && created.length > 0
? rejectWithValue({ ...normalized, created })
: normalized;
}
},
);
export const createArticle = createAsyncThunk(
'category/createArticle',
async ({ parentId, name }, { rejectWithValue }) => {
@@ -153,6 +186,40 @@ export const createArticle = createAsyncThunk(
},
);
/**
* Create several articles under the same parent in one go (bulk add).
* Runs the single-create API sequentially so every article is created
* even if a later one fails; the first error is reported.
* Returns the array of created articles.
*/
export const createArticles = createAsyncThunk(
'category/createArticles',
async ({ parentId, names }, { rejectWithValue }) => {
const list = (names ?? [])
.map((n) => String(n ?? '').trim())
.filter(Boolean);
if (list.length === 0) {
return rejectWithValue({ message: 'Keine Namen übergeben.' });
}
const created = [];
try {
for (const name of list) {
const data = await article.create({ parentId, name });
requireObject(data.article, 'POST /api/article.article');
requireDefined(data.article.id, 'article.id');
created.push(data.article);
}
return created;
} catch (err) {
const normalized = catchBackendError(rejectWithValue)(err);
// Attach already-created articles so the UI can still update optimistically.
return normalized && created.length > 0
? rejectWithValue({ ...normalized, created })
: normalized;
}
},
);
export const renameCategory = createAsyncThunk(
'category/rename',
async ({ categoryId, name }, { rejectWithValue }) => {
@@ -434,6 +501,15 @@ const categorySlice = createSlice({
if (!state.categories) state.categories = {};
state.categories[String(action.payload.id)] = action.payload;
})
.addCase(createCategories.fulfilled, (state, action) => {
const createdList = action.payload;
if (!Array.isArray(createdList)) return;
if (!state.categories) state.categories = {};
for (const item of createdList) {
if (item == null || item.id == null) continue;
state.categories[String(item.id)] = item;
}
})
.addCase(renameCategory.fulfilled, (state, action) => {
const { id, name } = action.payload;
if (id == null || name == null) return;
@@ -495,6 +571,28 @@ const categorySlice = createSlice({
state.rootArticles = [...state.rootArticles, created];
}
})
.addCase(createArticles.fulfilled, (state, action) => {
const createdList = action.payload;
if (!Array.isArray(createdList)) return;
const added = createdList.filter((item) => item != null);
if (added.length === 0) return;
if (added.some((item) => String(item.parentId) === String(state.currentCategoryId))) {
state.articles = [
...state.articles,
...added.filter((item) =>
String(item.parentId) === String(state.currentCategoryId),
),
];
state.articlesTotal = state.articles.length;
state.articlesStatus = 'succeeded';
}
const rootAdded = added.filter((item) =>
String(item.parentId) === String(state.rootCategoryId),
);
if (rootAdded.length > 0) {
state.rootArticles = [...state.rootArticles, ...rootAdded];
}
})
.addCase(deleteCategory.fulfilled, (state, action) => {
if (!state.categories) return;
delete state.categories[String(action.payload)];