u
This commit is contained in:
@@ -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';
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
export default function ArticleNameDialog({ open, onClose, onSubmit }) {
|
|
||||||
return (
|
return (
|
||||||
<NameDialog
|
<Dialog open={open} onClose={this.handleClose} maxWidth="xs" fullWidth>
|
||||||
open={open}
|
<DialogTitle>Neuer Artikel</DialogTitle>
|
||||||
title="Neuer Artikel"
|
|
||||||
submitLabel="Anlegen"
|
{!isBulk && (
|
||||||
onClose={onClose}
|
<Box component="form" onSubmit={this.handleSingleSubmit} noValidate>
|
||||||
onSubmit={onSubmit}
|
<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;
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { Component } from 'react';
|
import { Component } from 'react';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import { ActionTile } from '../tiles';
|
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';
|
import ArticleNameDialog from './ArticleNameDialog';
|
||||||
|
|
||||||
class NewArticleTile extends Component {
|
class NewArticleTile extends Component {
|
||||||
@@ -21,12 +24,22 @@ class NewArticleTile extends Component {
|
|||||||
this.setState({ open: false });
|
this.setState({ open: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleSubmit({ name }) {
|
async handleSubmit({ names }) {
|
||||||
const { parentId } = this.props;
|
const { parentId } = this.props;
|
||||||
const article = await this.props.createArticle({ parentId, name }).unwrap();
|
const list = (names ?? []).filter(Boolean);
|
||||||
|
if (list.length <= 1) {
|
||||||
|
const article = await this.props
|
||||||
|
.createArticle({ parentId, name: list[0] ?? '' })
|
||||||
|
.unwrap();
|
||||||
if (article) {
|
if (article) {
|
||||||
this.props.onOpenArticle?.(article);
|
this.props.onOpenArticle?.(article);
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const created = await this.props.createArticles({ parentId, names: list }).unwrap();
|
||||||
|
if (created?.[0]) {
|
||||||
|
this.props.onOpenArticle?.(created[0]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
@@ -51,6 +64,7 @@ class NewArticleTile extends Component {
|
|||||||
|
|
||||||
const mapDispatchToProps = {
|
const mapDispatchToProps = {
|
||||||
createArticle: createArticleThunk,
|
createArticle: createArticleThunk,
|
||||||
|
createArticles: createArticlesThunk,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default connect(null, mapDispatchToProps)(NewArticleTile);
|
export default connect(null, mapDispatchToProps)(NewArticleTile);
|
||||||
|
|||||||
@@ -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 = {
|
const TITLES = {
|
||||||
create: 'Neue Kategorie',
|
create: 'Neue Kategorie',
|
||||||
@@ -10,21 +20,206 @@ const SUBMIT_LABELS = {
|
|||||||
edit: 'Speichern',
|
edit: 'Speichern',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function CategoryNameDialog({
|
const BULK_MODE = 'bulk';
|
||||||
open,
|
const SINGLE_MODE = 'single';
|
||||||
mode = 'create',
|
|
||||||
initialName,
|
/** Split a bulk textarea into non-empty, trimmed names (one per line). */
|
||||||
onClose,
|
function parseBulkNames(text) {
|
||||||
onSubmit,
|
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 (
|
return (
|
||||||
<NameDialog
|
<Dialog open={open} onClose={this.handleClose} maxWidth="xs" fullWidth>
|
||||||
open={open}
|
<DialogTitle>{TITLES[mode]}</DialogTitle>
|
||||||
title={TITLES[mode]}
|
|
||||||
submitLabel={SUBMIT_LABELS[mode]}
|
{!isBulk && (
|
||||||
initialName={initialName}
|
<Box component="form" onSubmit={this.handleSingleSubmit} noValidate>
|
||||||
onClose={onClose}
|
<DialogContent>
|
||||||
onSubmit={onSubmit}
|
{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;
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { Component } from 'react';
|
import { Component } from 'react';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import { ActionTile } from '../tiles';
|
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';
|
import CategoryNameDialog from './CategoryNameDialog';
|
||||||
|
|
||||||
class NewCategoryTile extends Component {
|
class NewCategoryTile extends Component {
|
||||||
@@ -21,9 +24,14 @@ class NewCategoryTile extends Component {
|
|||||||
this.setState({ open: false });
|
this.setState({ open: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleSubmit({ name }) {
|
async handleSubmit({ names }) {
|
||||||
const { parentId } = this.props;
|
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() {
|
render() {
|
||||||
@@ -49,6 +57,7 @@ class NewCategoryTile extends Component {
|
|||||||
|
|
||||||
const mapDispatchToProps = {
|
const mapDispatchToProps = {
|
||||||
createCategory: createCategoryThunk,
|
createCategory: createCategoryThunk,
|
||||||
|
createCategories: createCategoriesThunk,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default connect(null, mapDispatchToProps)(NewCategoryTile);
|
export default connect(null, mapDispatchToProps)(NewCategoryTile);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Component } from 'react';
|
import { Component, PureComponent } from 'react';
|
||||||
import Box from '@mui/material/Box';
|
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';
|
||||||
@@ -74,7 +74,7 @@ function TopBand({ onSettingsClick, settingsTab, terminalCount }) {
|
|||||||
/**
|
/**
|
||||||
* 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, onPhotoUpload, onPhotoDelete, categoryFullHash }) {
|
function MiddleBand({ settingsTab, nameInput, terminals, categoryPosterminalIds, onTerminalToggle, onPhotoUpload, onPhotoDelete, fullHash }) {
|
||||||
const fillSx = {
|
const fillSx = {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
@@ -121,7 +121,7 @@ function MiddleBand({ settingsTab, nameInput, terminals, categoryPosterminalIds,
|
|||||||
noWrap
|
noWrap
|
||||||
sx={{ maxWidth: 80, color: 'text.secondary' }}
|
sx={{ maxWidth: 80, color: 'text.secondary' }}
|
||||||
>
|
>
|
||||||
{categoryFullHash ? 'Löschen' : 'Hochladen'}
|
{fullHash ? 'Löschen' : 'Hochladen'}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -217,7 +217,7 @@ function BottomRow({ onOpen, editBand }) {
|
|||||||
* Renders a ThumbHash preview and the full thumbnail as backdrop for a category tile.
|
* 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.
|
* ThumbHash is shown immediately as a placeholder; the thumb fades in once loaded.
|
||||||
*/
|
*/
|
||||||
class CategoryBackdrop extends Component {
|
class CategoryBackdrop extends PureComponent {
|
||||||
state = { thumbLoaded: false };
|
state = { thumbLoaded: false };
|
||||||
|
|
||||||
componentDidUpdate(prevProps) {
|
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 {
|
export class CategoryTile extends Component {
|
||||||
|
state = { settingsTab: -1 }; // -1 = rename, -2 = photo, 0..n-1 = terminal index
|
||||||
|
|
||||||
handleClick = () => {
|
handleClick = () => {
|
||||||
this.props.onClick?.();
|
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() {
|
renderLabel() {
|
||||||
const { label, nameInput } = this.props;
|
const { label, nameInput } = this.props;
|
||||||
if (label == null) return null;
|
if (label == null) return null;
|
||||||
@@ -304,97 +343,34 @@ 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() {
|
render() {
|
||||||
const {
|
const {
|
||||||
|
sx,
|
||||||
|
edit,
|
||||||
label,
|
label,
|
||||||
|
thumbHash,
|
||||||
|
fullHash,
|
||||||
|
onClick,
|
||||||
nameInput,
|
nameInput,
|
||||||
editBand,
|
editBand,
|
||||||
onOpen,
|
onOpen,
|
||||||
terminals,
|
terminals,
|
||||||
categoryPosterminalIds,
|
categoryPosterminalIds,
|
||||||
onTerminalToggle,
|
onTerminalToggle,
|
||||||
hasTerminals: _hasTerminals,
|
onPhotoUpload,
|
||||||
categoryFullHash,
|
onPhotoDelete,
|
||||||
thumbHash,
|
|
||||||
onPhotoUpload: _onPhotoUpload,
|
|
||||||
onPhotoDelete: _onPhotoDelete,
|
|
||||||
sx,
|
|
||||||
...rest
|
...rest
|
||||||
} = this.props;
|
} = this.props;
|
||||||
const { settingsTab } = this.state;
|
const { settingsTab } = this.state;
|
||||||
|
|
||||||
return (
|
// Shared by both modes: the thumbnail backdrop.
|
||||||
<Box
|
const backdrop = (
|
||||||
{...rest}
|
<CategoryBackdrop thumbHash={thumbHash} fullHash={fullHash} />
|
||||||
sx={{
|
);
|
||||||
...tileBaseSx,
|
|
||||||
cursor: 'default',
|
// Only the overlay differs between normal and edit mode.
|
||||||
position: 'relative',
|
const overlay = edit ? (
|
||||||
overflow: 'hidden',
|
<>
|
||||||
'&:hover': { bgcolor: 'background.paper', boxShadow: 0 },
|
|
||||||
...sx,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CategoryBackdrop thumbHash={thumbHash} fullHash={categoryFullHash} />
|
|
||||||
<Box sx={{ position: 'absolute', inset: 0, zIndex: 1 }}>
|
|
||||||
<TopBand
|
<TopBand
|
||||||
onSettingsClick={this.handleSettingsToggle}
|
onSettingsClick={this.handleSettingsToggle}
|
||||||
settingsTab={settingsTab}
|
settingsTab={settingsTab}
|
||||||
@@ -417,10 +393,35 @@ export class CategoryEditTile extends Component {
|
|||||||
terminals={terminals}
|
terminals={terminals}
|
||||||
categoryPosterminalIds={categoryPosterminalIds}
|
categoryPosterminalIds={categoryPosterminalIds}
|
||||||
onTerminalToggle={onTerminalToggle}
|
onTerminalToggle={onTerminalToggle}
|
||||||
categoryFullHash={categoryFullHash}
|
fullHash={fullHash}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
<BottomRow onOpen={onOpen} editBand={editBand} />
|
<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,
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
...(edit
|
||||||
|
? { cursor: 'default', '&:hover': { bgcolor: 'background.paper', boxShadow: 0 } }
|
||||||
|
: { cursor: 'pointer', '&:hover': { bgcolor: 'action.hover' } }),
|
||||||
|
...sx,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{backdrop}
|
||||||
|
<Box sx={{ position: 'absolute', inset: 0, zIndex: 1 }}>
|
||||||
|
{overlay}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
export { TILE_SIZE, Tile, tileBaseSx } from 'dashgui/tiles';
|
export { TILE_SIZE, Tile, tileBaseSx } from 'dashgui/tiles';
|
||||||
export { BackTile } from './BackTile';
|
export { BackTile } from './BackTile';
|
||||||
export { CategoryTile, CategoryEditTile } from './CategoryTile';
|
export { CategoryTile } from './CategoryTile';
|
||||||
export { ArticleTile } from './ArticleTile';
|
export { ArticleTile } from './ArticleTile';
|
||||||
export { ActionTile } from './ActionTile';
|
export { ActionTile } from './ActionTile';
|
||||||
|
|||||||
@@ -14,14 +14,13 @@ import { notifyError } from '../../utils/notify';
|
|||||||
import {
|
import {
|
||||||
ArticleTile,
|
ArticleTile,
|
||||||
BackTile,
|
BackTile,
|
||||||
CategoryEditTile,
|
|
||||||
CategoryTile,
|
CategoryTile,
|
||||||
} from '../../components/tiles';
|
} from '../../components/tiles';
|
||||||
import { TILE_SIZE } from 'dashgui/tiles';
|
import { TILE_SIZE } from 'dashgui/tiles';
|
||||||
import withCategoryRoute from './withCategoryRoute';
|
import withCategoryRoute from './withCategoryRoute';
|
||||||
import { artikelDetailPath, articleSku } from './articleRoute';
|
import { artikelDetailPath, articleSku } from './articleRoute';
|
||||||
import { selectCompanyId } from '../../store/companySlice';
|
import { selectCompanyId } from '../../store/companySlice';
|
||||||
import { selectHasPosterminals, selectPosterminals, loadPosterminals } from '../../store/posterminalSlice';
|
import { selectPosterminals, loadPosterminals } from '../../store/posterminalSlice';
|
||||||
import {
|
import {
|
||||||
buildCategoryPath,
|
buildCategoryPath,
|
||||||
defaultCategoryPath,
|
defaultCategoryPath,
|
||||||
@@ -281,7 +280,6 @@ class ArtikelKategorienPage extends Component {
|
|||||||
childCategories,
|
childCategories,
|
||||||
articles,
|
articles,
|
||||||
renameMode,
|
renameMode,
|
||||||
hasTerminals,
|
|
||||||
terminals,
|
terminals,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
@@ -346,20 +344,19 @@ class ArtikelKategorienPage extends Component {
|
|||||||
|
|
||||||
{childCategories.map((item, index) => {
|
{childCategories.map((item, index) => {
|
||||||
const label = categoryDisplayName(item.name, item.id, rootCategoryId);
|
const label = categoryDisplayName(item.name, item.id, rootCategoryId);
|
||||||
if (renameMode) {
|
|
||||||
const draftName =
|
const draftName =
|
||||||
this.state.drafts[item.id] !== undefined
|
this.state.drafts[item.id] !== undefined
|
||||||
? this.state.drafts[item.id]
|
? this.state.drafts[item.id]
|
||||||
: label;
|
: label;
|
||||||
return (
|
const editProps = renameMode
|
||||||
<CategoryEditTile
|
? {
|
||||||
key={item.id}
|
edit: true,
|
||||||
data-id={String(item.id)}
|
'data-id': String(item.id),
|
||||||
className="sortable-item"
|
className: 'sortable-item',
|
||||||
label={draftName}
|
label: draftName,
|
||||||
terminals={terminals}
|
terminals,
|
||||||
categoryPosterminalIds={item.posterminalIds || []}
|
categoryPosterminalIds: item.posterminalIds || [],
|
||||||
onTerminalToggle={(terminalId, enabled) => {
|
onTerminalToggle: (terminalId, enabled) => {
|
||||||
const thunk = enabled
|
const thunk = enabled
|
||||||
? this.props.enableTerminalCategory
|
? this.props.enableTerminalCategory
|
||||||
: this.props.disableTerminalCategory;
|
: this.props.disableTerminalCategory;
|
||||||
@@ -373,12 +370,9 @@ class ArtikelKategorienPage extends Component {
|
|||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch(notifyError);
|
.catch(notifyError);
|
||||||
}}
|
},
|
||||||
onOpen={() => this.navigateToCategoryById(item.id)}
|
onOpen: () => this.navigateToCategoryById(item.id),
|
||||||
hasTerminals={hasTerminals}
|
onPhotoUpload: () => {
|
||||||
categoryFullHash={item.fullHash}
|
|
||||||
thumbHash={item.thumbHash}
|
|
||||||
onPhotoUpload={() => {
|
|
||||||
const input = document.createElement('input');
|
const input = document.createElement('input');
|
||||||
input.type = 'file';
|
input.type = 'file';
|
||||||
input.accept = 'image/*';
|
input.accept = 'image/*';
|
||||||
@@ -398,12 +392,12 @@ class ArtikelKategorienPage extends Component {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
input.click();
|
input.click();
|
||||||
}}
|
},
|
||||||
onPhotoDelete={() => {
|
onPhotoDelete: () => {
|
||||||
void this.props.deleteCategoryImage(item.id)
|
void this.props.deleteCategoryImage(item.id)
|
||||||
.catch(notifyError);
|
.catch(notifyError);
|
||||||
}}
|
},
|
||||||
nameInput={
|
nameInput: (
|
||||||
<TextField
|
<TextField
|
||||||
value={draftName}
|
value={draftName}
|
||||||
onChange={(e) => this.handleDraftChange(item.id, e.target.value)}
|
onChange={(e) => this.handleDraftChange(item.id, e.target.value)}
|
||||||
@@ -429,24 +423,26 @@ class ArtikelKategorienPage extends Component {
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
}
|
),
|
||||||
editBand={{
|
editBand: {
|
||||||
onMoveLeft: () => this.handleMoveCategory(item, -1),
|
onMoveLeft: () => this.handleMoveCategory(item, -1),
|
||||||
onMoveRight: () => this.handleMoveCategory(item, 1),
|
onMoveRight: () => this.handleMoveCategory(item, 1),
|
||||||
onDelete: () => this.handleDeleteCategory(item),
|
onDelete: () => this.handleDeleteCategory(item),
|
||||||
canLeft: index > 0,
|
canLeft: index > 0,
|
||||||
canRight: index < childCategories.length - 1,
|
canRight: index < childCategories.length - 1,
|
||||||
}}
|
},
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
: {
|
||||||
|
label,
|
||||||
|
onClick: () => this.navigateToCategoryById(item.id),
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CategoryTile
|
<CategoryTile
|
||||||
key={item.id}
|
key={item.id}
|
||||||
label={label}
|
|
||||||
onClick={() => this.navigateToCategoryById(item.id)}
|
|
||||||
thumbHash={item.thumbHash}
|
thumbHash={item.thumbHash}
|
||||||
fullHash={item.fullHash}
|
fullHash={item.fullHash}
|
||||||
|
{...editProps}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -493,7 +489,6 @@ const mapStateToProps = (state) => ({
|
|||||||
articles: selectCategoryArticles(state),
|
articles: selectCategoryArticles(state),
|
||||||
canGoBack: selectCanNavigateBack(state),
|
canGoBack: selectCanNavigateBack(state),
|
||||||
renameMode: selectRenameMode(state),
|
renameMode: selectRenameMode(state),
|
||||||
hasTerminals: selectHasPosterminals(state),
|
|
||||||
terminals: selectPosterminals(state),
|
terminals: selectPosterminals(state),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
export const createArticle = createAsyncThunk(
|
||||||
'category/createArticle',
|
'category/createArticle',
|
||||||
async ({ parentId, name }, { rejectWithValue }) => {
|
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(
|
export const renameCategory = createAsyncThunk(
|
||||||
'category/rename',
|
'category/rename',
|
||||||
async ({ categoryId, name }, { rejectWithValue }) => {
|
async ({ categoryId, name }, { rejectWithValue }) => {
|
||||||
@@ -434,6 +501,15 @@ const categorySlice = createSlice({
|
|||||||
if (!state.categories) state.categories = {};
|
if (!state.categories) state.categories = {};
|
||||||
state.categories[String(action.payload.id)] = action.payload;
|
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) => {
|
.addCase(renameCategory.fulfilled, (state, action) => {
|
||||||
const { id, name } = action.payload;
|
const { id, name } = action.payload;
|
||||||
if (id == null || name == null) return;
|
if (id == null || name == null) return;
|
||||||
@@ -495,6 +571,28 @@ const categorySlice = createSlice({
|
|||||||
state.rootArticles = [...state.rootArticles, created];
|
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) => {
|
.addCase(deleteCategory.fulfilled, (state, action) => {
|
||||||
if (!state.categories) return;
|
if (!state.categories) return;
|
||||||
delete state.categories[String(action.payload)];
|
delete state.categories[String(action.payload)];
|
||||||
|
|||||||
Reference in New Issue
Block a user