u
This commit is contained in:
@@ -60,7 +60,7 @@ class CompanySettingsDialog extends Component {
|
||||
syncFormFromCompany(company) {
|
||||
this.setState({
|
||||
name: company.name ?? '',
|
||||
iso639: company.iso639 ?? 'de',
|
||||
iso639: company.iso639,
|
||||
taxrate: company.taxrate != null ? String(company.taxrate) : '',
|
||||
reducedtaxrate: company.reducedtaxrate != null ? String(company.reducedtaxrate) : '',
|
||||
});
|
||||
|
||||
107
src/components/NameDialog.jsx
Normal file
107
src/components/NameDialog.jsx
Normal file
@@ -0,0 +1,107 @@
|
||||
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 TextField from '@mui/material/TextField';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import BackendError from './BackendError';
|
||||
import { backendErrorFrom, isBackendFailure } from '../utils/backendError';
|
||||
|
||||
/**
|
||||
* Single-field name dialog (create/edit).
|
||||
* Props: open, title, submitLabel, onClose, onSubmit({ name }),
|
||||
* optional initialName (form start value when opened).
|
||||
*/
|
||||
class NameDialog extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
name: '',
|
||||
busy: false,
|
||||
error: null,
|
||||
};
|
||||
this.handleSubmit = this.handleSubmit.bind(this);
|
||||
this.handleClose = this.handleClose.bind(this);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (this.props.open && !prevProps.open) {
|
||||
this.setState({
|
||||
name: this.props.initialName ?? '',
|
||||
busy: false,
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleClose() {
|
||||
if (this.state.busy) return;
|
||||
this.props.onClose();
|
||||
}
|
||||
|
||||
async handleSubmit(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({ name });
|
||||
this.props.onClose();
|
||||
} catch (err) {
|
||||
this.setState({ error: backendErrorFrom(err), busy: false });
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { open, title, submitLabel } = this.props;
|
||||
const { name, busy, error } = this.state;
|
||||
const canSave = name.trim() !== '';
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={this.handleClose} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<Box component="form" onSubmit={this.handleSubmit} 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}
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
required
|
||||
autoFocus
|
||||
label="Name"
|
||||
value={name}
|
||||
disabled={busy}
|
||||
onChange={(e) => this.setState({ name: e.target.value })}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<Button type="button" onClick={this.handleClose} disabled={busy}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="submit" variant="contained" disabled={busy || !canSave}>
|
||||
{submitLabel}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Box>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default NameDialog;
|
||||
@@ -4,21 +4,24 @@ import Tooltip from '@mui/material/Tooltip';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { artikelQueryPath } from '../pages/artikel/artikelQuery';
|
||||
import { defaultCategoryPath, isKategorienPath } from '../pages/artikel/categoryRoute';
|
||||
|
||||
const SUB_NAV_WIDTH = 80;
|
||||
|
||||
function isSubNavActive(pathname, basePath, segment, knownSegments) {
|
||||
if (!segment) {
|
||||
if (pathname === basePath || pathname === `${basePath}/`) return true;
|
||||
// /artikel/:sku detail — highlight overview, not kategorien/lager
|
||||
const rest = pathname.slice(basePath.length).replace(/^\//, '');
|
||||
return Boolean(rest) && !rest.includes('/') && !knownSegments.has(rest);
|
||||
}
|
||||
if (segment === 'kategorien') {
|
||||
return isKategorienPath(pathname);
|
||||
}
|
||||
const subPath = `${basePath}/${segment}`;
|
||||
return pathname === subPath || pathname === `${subPath}/`;
|
||||
}
|
||||
|
||||
export default function PageLayout({ title, subNav, basePath, children }) {
|
||||
export default function PageLayout({ title, showTitle = true, subNav, basePath, children }) {
|
||||
const hasSubNav = subNav?.length > 0;
|
||||
const navigate = useNavigate();
|
||||
const { pathname } = useLocation();
|
||||
@@ -55,6 +58,10 @@ export default function PageLayout({ title, subNav, basePath, children }) {
|
||||
navigate(artikelQueryPath());
|
||||
return;
|
||||
}
|
||||
if (segment === 'kategorien') {
|
||||
navigate(defaultCategoryPath());
|
||||
return;
|
||||
}
|
||||
navigate(segment ? `${basePath}/${segment}` : basePath);
|
||||
}}
|
||||
sx={(theme) => ({
|
||||
@@ -77,9 +84,11 @@ export default function PageLayout({ title, subNav, basePath, children }) {
|
||||
</Box>
|
||||
|
||||
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700, mb: 2 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
{showTitle ? (
|
||||
<Typography variant="h5" sx={{ fontWeight: 700, mb: 2 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
) : null}
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -1,27 +1,34 @@
|
||||
import { Outlet, useLocation } from 'react-router-dom';
|
||||
import PageLayout from './PageLayout';
|
||||
import { NAV_ITEMS, SUB_NAV } from '../nav';
|
||||
import { isKategorienPath } from '../pages/artikel/categoryRoute';
|
||||
|
||||
export default function SectionLayout({ sectionPath }) {
|
||||
const { pathname } = useLocation();
|
||||
const title = NAV_ITEMS.find((item) => item.path === sectionPath)?.label ?? '';
|
||||
const subNav = SUB_NAV[sectionPath];
|
||||
const activeSub = subNav?.find((item) => {
|
||||
if (item.segment === 'kategorien') {
|
||||
return isKategorienPath(pathname);
|
||||
}
|
||||
const subPath = item.segment ? `${sectionPath}/${item.segment}` : sectionPath;
|
||||
return pathname === subPath || pathname === `${subPath}/`;
|
||||
});
|
||||
|
||||
const knownSegments = new Set((subNav ?? []).map((item) => item.segment).filter(Boolean));
|
||||
const rest = pathname.slice(sectionPath.length).replace(/^\//, '').split('/')[0];
|
||||
const detailSku =
|
||||
sectionPath === '/artikel' && rest && !knownSegments.has(rest)
|
||||
? decodeURIComponent(rest)
|
||||
: null;
|
||||
const isArticleDetail =
|
||||
sectionPath === '/artikel' &&
|
||||
!isKategorienPath(pathname) &&
|
||||
rest &&
|
||||
!knownSegments.has(rest);
|
||||
|
||||
const pageTitle = detailSku ?? (activeSub && subNav ? activeSub.label : title);
|
||||
const pageTitle = isArticleDetail
|
||||
? title
|
||||
: (activeSub?.label ?? title);
|
||||
|
||||
return (
|
||||
<PageLayout title={pageTitle} subNav={subNav} basePath={sectionPath}>
|
||||
<PageLayout title={pageTitle} showTitle={!isArticleDetail} subNav={subNav} basePath={sectionPath}>
|
||||
<Outlet />
|
||||
</PageLayout>
|
||||
);
|
||||
|
||||
13
src/components/article/ArticleNameDialog.jsx
Normal file
13
src/components/article/ArticleNameDialog.jsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import NameDialog from '../NameDialog';
|
||||
|
||||
export default function ArticleNameDialog({ open, onClose, onSubmit }) {
|
||||
return (
|
||||
<NameDialog
|
||||
open={open}
|
||||
title="Neuer Artikel"
|
||||
submitLabel="Anlegen"
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
209
src/components/article/ArticleView.jsx
Normal file
209
src/components/article/ArticleView.jsx
Normal file
@@ -0,0 +1,209 @@
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import Box from '@mui/material/Box';
|
||||
import Link from '@mui/material/Link';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { article } from '../../api';
|
||||
import { EditablePanelTile, PanelTile, PanelTileGrid } from '../panels';
|
||||
import { requireObject } from '../../utils/apiShape';
|
||||
import { formatPrice } from '../../utils/price';
|
||||
import { formatStock } from '../../pages/artikel/searchFormat';
|
||||
|
||||
function PanelValue({ label, children }) {
|
||||
if (children == null || children === '') return null;
|
||||
return (
|
||||
<Box sx={{ mb: 1 }}>
|
||||
{label ? (
|
||||
<Typography variant="caption" color="text.secondary" display="block">
|
||||
{label}
|
||||
</Typography>
|
||||
) : null}
|
||||
<Typography variant="body2" component="div">
|
||||
{children}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function ArticlePhoto() {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
width: 200,
|
||||
height: 200,
|
||||
maxWidth: '100%',
|
||||
bgcolor: 'action.hover',
|
||||
borderRadius: 1,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const headerTileSx = {
|
||||
p: 1.25,
|
||||
minHeight: 0,
|
||||
};
|
||||
|
||||
function CountLine({ label, count }) {
|
||||
if (count == null) return null;
|
||||
return (
|
||||
<Typography variant="body2">
|
||||
{label}: {count}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
function RelationLine({ label, active, children }) {
|
||||
return (
|
||||
<Typography variant="body2" sx={{ mb: 0.5 }}>
|
||||
{label}:{' '}
|
||||
{active ? (
|
||||
children ?? 'ja'
|
||||
) : (
|
||||
<Typography component="span" variant="body2" color="text.secondary">
|
||||
nein
|
||||
</Typography>
|
||||
)}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
function articleBrutto(article) {
|
||||
if (article.net == null || article.taxRate == null) return null;
|
||||
return article.net * (1 + article.taxRate / 100);
|
||||
}
|
||||
|
||||
function nameDraftDirty(draft, saved) {
|
||||
return draft.name.trim() !== String(saved.name ?? '').trim();
|
||||
}
|
||||
|
||||
function nameDraftValid(draft) {
|
||||
return draft.name.trim().length > 0;
|
||||
}
|
||||
|
||||
export default function ArticleView({ article: articleData, onArticleUpdated }) {
|
||||
const brutto = articleBrutto(articleData);
|
||||
|
||||
const handleSaveName = async (draft) => {
|
||||
const name = draft.name.trim();
|
||||
const res = await article.update(articleData.sku, { name });
|
||||
requireObject(res, 'PATCH /api/article/:sku');
|
||||
requireObject(res.article, 'article');
|
||||
onArticleUpdated?.(res.article);
|
||||
};
|
||||
|
||||
return (
|
||||
<PanelTileGrid>
|
||||
<EditablePanelTile
|
||||
wide={3}
|
||||
minHeight={0}
|
||||
sx={headerTileSx}
|
||||
value={{ name: articleData.name ?? '' }}
|
||||
onSave={handleSaveName}
|
||||
isDirty={nameDraftDirty}
|
||||
canSave={nameDraftValid}
|
||||
renderDisplay={({ name }) => (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
minHeight: { xs: 0, sm: 200 },
|
||||
pr: 4,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, lineHeight: 1.25 }}>
|
||||
{name}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
renderEdit={(draft, onChange, inputRef) => (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
minHeight: { xs: 0, sm: 200 },
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
inputRef={inputRef}
|
||||
fullWidth
|
||||
variant="standard"
|
||||
value={draft.name}
|
||||
onChange={(event) => onChange({ name: event.target.value })}
|
||||
sx={{
|
||||
'& .MuiInput-input': {
|
||||
fontSize: '1.25rem',
|
||||
fontWeight: 700,
|
||||
lineHeight: 1.25,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
/>
|
||||
|
||||
<PanelTile wide={1} minHeight={0} sx={headerTileSx}>
|
||||
<ArticlePhoto />
|
||||
</PanelTile>
|
||||
|
||||
<PanelTile wide={1} title="Bestand">
|
||||
<PanelValue>
|
||||
{articleData.stock != null ? formatStock(articleData.stock) : null}
|
||||
</PanelValue>
|
||||
</PanelTile>
|
||||
|
||||
<PanelTile wide={1} title="Marke">
|
||||
<PanelValue>{articleData.brand}</PanelValue>
|
||||
</PanelTile>
|
||||
|
||||
<PanelTile wide={1} title="SKU / EAN">
|
||||
<PanelValue label="SKU">{articleData.sku}</PanelValue>
|
||||
<PanelValue label="EAN">{articleData.ean}</PanelValue>
|
||||
</PanelTile>
|
||||
|
||||
<PanelTile wide={1} title="Preis">
|
||||
<PanelValue label="Netto">
|
||||
{articleData.net != null ? formatPrice(articleData.net) : null}
|
||||
</PanelValue>
|
||||
<PanelValue label="MwSt.">
|
||||
{articleData.taxRate != null ? `${articleData.taxRate} %` : null}
|
||||
</PanelValue>
|
||||
<PanelValue label="Brutto">
|
||||
{brutto != null ? formatPrice(brutto) : null}
|
||||
</PanelValue>
|
||||
</PanelTile>
|
||||
|
||||
<PanelTile wide={3} title="SEO & Beschreibung" minHeight={200}>
|
||||
<PanelValue label="Slug">{articleData.slug}</PanelValue>
|
||||
<PanelValue label="SEO-Titel">{articleData.seoTitle}</PanelValue>
|
||||
<PanelValue label="SEO-Beschreibung">{articleData.seoDescription}</PanelValue>
|
||||
<PanelValue label="Beschreibung">{articleData.description}</PanelValue>
|
||||
</PanelTile>
|
||||
|
||||
<PanelTile wide={1} title="Listen" minHeight={200}>
|
||||
<RelationLine
|
||||
label="Vaterartikel"
|
||||
active={articleData.childCount > 0}
|
||||
>
|
||||
{articleData.childCount}
|
||||
</RelationLine>
|
||||
<RelationLine
|
||||
label="Kindartikel"
|
||||
active={articleData.fatherArticleId != null}
|
||||
>
|
||||
{articleData.fatherSku ? (
|
||||
<Link
|
||||
component={RouterLink}
|
||||
to={`/artikel/${encodeURIComponent(articleData.fatherSku)}`}
|
||||
>
|
||||
{articleData.fatherSku}
|
||||
</Link>
|
||||
) : null}
|
||||
</RelationLine>
|
||||
<CountLine label="Optionen" count={articleData.optionsCount} />
|
||||
<CountLine label="Stücklisten" count={articleData.articleListCount} />
|
||||
<CountLine label="Varianten" count={articleData.childCount} />
|
||||
</PanelTile>
|
||||
</PanelTileGrid>
|
||||
);
|
||||
}
|
||||
58
src/components/article/NewArticleTile.jsx
Normal file
58
src/components/article/NewArticleTile.jsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
import { ActionTile } from '../tiles';
|
||||
import { createArticle as createArticleThunk } from '../../store/categorySlice';
|
||||
import ArticleNameDialog from './ArticleNameDialog';
|
||||
|
||||
class NewArticleTile extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { open: false };
|
||||
this.handleOpen = this.handleOpen.bind(this);
|
||||
this.handleClose = this.handleClose.bind(this);
|
||||
this.handleSubmit = this.handleSubmit.bind(this);
|
||||
}
|
||||
|
||||
handleOpen() {
|
||||
this.setState({ open: true });
|
||||
}
|
||||
|
||||
handleClose() {
|
||||
this.setState({ open: false });
|
||||
}
|
||||
|
||||
async handleSubmit({ name }) {
|
||||
const { parentId } = this.props;
|
||||
const article = await this.props.createArticle({ parentId, name }).unwrap();
|
||||
enqueueSnackbar('Artikel angelegt.', { variant: 'success' });
|
||||
if (article) {
|
||||
this.props.onOpenArticle?.(article);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { parentId, disabled } = this.props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ActionTile
|
||||
label="Neuer Artikel"
|
||||
disabled={disabled || parentId == null}
|
||||
onClick={this.handleOpen}
|
||||
/>
|
||||
<ArticleNameDialog
|
||||
open={this.state.open}
|
||||
onClose={this.handleClose}
|
||||
onSubmit={this.handleSubmit}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
createArticle: createArticleThunk,
|
||||
};
|
||||
|
||||
export default connect(null, mapDispatchToProps)(NewArticleTile);
|
||||
3
src/components/article/index.js
Normal file
3
src/components/article/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as ArticleView } from './ArticleView';
|
||||
export { default as ArticleNameDialog } from './ArticleNameDialog';
|
||||
export { default as NewArticleTile } from './NewArticleTile';
|
||||
42
src/components/category/CategoryBreadcrumbs.jsx
Normal file
42
src/components/category/CategoryBreadcrumbs.jsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Component } from 'react';
|
||||
import Breadcrumbs from '@mui/material/Breadcrumbs';
|
||||
import Link from '@mui/material/Link';
|
||||
|
||||
class CategoryBreadcrumbs extends Component {
|
||||
handleNavigate = (categoryId) => {
|
||||
this.props.onNavigate(categoryId);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { crumbs } = this.props;
|
||||
const visible = (crumbs ?? []).filter((crumb) => crumb.name);
|
||||
if (visible.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Breadcrumbs sx={{ mb: 2 }} aria-label="Kategoriepfad">
|
||||
{visible.map((crumb, index) => (
|
||||
<Link
|
||||
key={crumb.id}
|
||||
component="button"
|
||||
type="button"
|
||||
variant="body2"
|
||||
underline="always"
|
||||
color="inherit"
|
||||
aria-current={index === visible.length - 1 ? 'page' : undefined}
|
||||
onClick={() => this.handleNavigate(crumb.id)}
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.43,
|
||||
p: 0,
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
{crumb.name}
|
||||
</Link>
|
||||
))}
|
||||
</Breadcrumbs>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default CategoryBreadcrumbs;
|
||||
30
src/components/category/CategoryNameDialog.jsx
Normal file
30
src/components/category/CategoryNameDialog.jsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import NameDialog from '../NameDialog';
|
||||
|
||||
const TITLES = {
|
||||
create: 'Neue Kategorie',
|
||||
edit: 'Kategorie bearbeiten',
|
||||
};
|
||||
|
||||
const SUBMIT_LABELS = {
|
||||
create: 'Anlegen',
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
21
src/components/category/EditCategoryDialog.jsx
Normal file
21
src/components/category/EditCategoryDialog.jsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Component } from 'react';
|
||||
import CategoryNameDialog from './CategoryNameDialog';
|
||||
|
||||
/** Edit flow — wire onSubmit when PUT /api/category/:id exists. */
|
||||
class EditCategoryDialog extends Component {
|
||||
render() {
|
||||
const { open, onClose, initialName, onSubmit } = this.props;
|
||||
|
||||
return (
|
||||
<CategoryNameDialog
|
||||
open={open}
|
||||
mode="edit"
|
||||
initialName={initialName}
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default EditCategoryDialog;
|
||||
56
src/components/category/NewCategoryTile.jsx
Normal file
56
src/components/category/NewCategoryTile.jsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
import { ActionTile } from '../tiles';
|
||||
import { createCategory as createCategoryThunk } from '../../store/categorySlice';
|
||||
import CategoryNameDialog from './CategoryNameDialog';
|
||||
|
||||
class NewCategoryTile extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { open: false };
|
||||
this.handleOpen = this.handleOpen.bind(this);
|
||||
this.handleClose = this.handleClose.bind(this);
|
||||
this.handleSubmit = this.handleSubmit.bind(this);
|
||||
}
|
||||
|
||||
handleOpen() {
|
||||
this.setState({ open: true });
|
||||
}
|
||||
|
||||
handleClose() {
|
||||
this.setState({ open: false });
|
||||
}
|
||||
|
||||
async handleSubmit({ name }) {
|
||||
const { parentId } = this.props;
|
||||
await this.props.createCategory({ parentId, name }).unwrap();
|
||||
enqueueSnackbar('Kategorie angelegt.', { variant: 'success' });
|
||||
}
|
||||
|
||||
render() {
|
||||
const { parentId, disabled } = this.props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ActionTile
|
||||
label="Neue Kategorie"
|
||||
disabled={disabled || parentId == null}
|
||||
onClick={this.handleOpen}
|
||||
/>
|
||||
<CategoryNameDialog
|
||||
open={this.state.open}
|
||||
mode="create"
|
||||
onClose={this.handleClose}
|
||||
onSubmit={this.handleSubmit}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
createCategory: createCategoryThunk,
|
||||
};
|
||||
|
||||
export default connect(null, mapDispatchToProps)(NewCategoryTile);
|
||||
4
src/components/category/index.js
Normal file
4
src/components/category/index.js
Normal file
@@ -0,0 +1,4 @@
|
||||
export { default as CategoryNameDialog } from './CategoryNameDialog';
|
||||
export { default as CategoryBreadcrumbs } from './CategoryBreadcrumbs';
|
||||
export { default as NewCategoryTile } from './NewCategoryTile';
|
||||
export { default as EditCategoryDialog } from './EditCategoryDialog';
|
||||
204
src/components/panels/EditablePanelTile.jsx
Normal file
204
src/components/panels/EditablePanelTile.jsx
Normal file
@@ -0,0 +1,204 @@
|
||||
import { Component, createRef } from 'react';
|
||||
import CloseOutlinedIcon from '@mui/icons-material/CloseOutlined';
|
||||
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
|
||||
import SaveOutlinedIcon from '@mui/icons-material/SaveOutlined';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import Box from '@mui/material/Box';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
import { backendErrorFrom } from '../../utils/backendError';
|
||||
import { panelTilePaperSx } from './panelTileLayout';
|
||||
|
||||
function valuesEqual(a, b) {
|
||||
return JSON.stringify(a) === JSON.stringify(b);
|
||||
}
|
||||
|
||||
/**
|
||||
* Panel tile with centralized inline-edit chrome.
|
||||
* Pass `onSave` to enable edit UI; omit it for read-only tiles (no edit icon).
|
||||
*/
|
||||
export class EditablePanelTile extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.inputRef = createRef();
|
||||
this.state = {
|
||||
editing: false,
|
||||
draft: null,
|
||||
saving: false,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate(_prevProps, prevState) {
|
||||
if (!prevState.editing && this.state.editing) {
|
||||
this.inputRef.current?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
getSavedValue() {
|
||||
return this.props.value;
|
||||
}
|
||||
|
||||
isDirty() {
|
||||
const { draft, editing } = this.state;
|
||||
if (!editing || draft == null) return false;
|
||||
if (this.props.isDirty) {
|
||||
return this.props.isDirty(draft, this.getSavedValue());
|
||||
}
|
||||
return !valuesEqual(draft, this.getSavedValue());
|
||||
}
|
||||
|
||||
canSave() {
|
||||
const { draft, saving } = this.state;
|
||||
if (saving || !this.isDirty() || draft == null) return false;
|
||||
if (this.props.canSave) {
|
||||
return this.props.canSave(draft, this.getSavedValue());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
handleStartEdit = (event) => {
|
||||
event.stopPropagation();
|
||||
this.setState({
|
||||
editing: true,
|
||||
draft: structuredClone(this.getSavedValue()),
|
||||
error: null,
|
||||
});
|
||||
};
|
||||
|
||||
handleCancel = (event) => {
|
||||
event.stopPropagation();
|
||||
this.setState({
|
||||
editing: false,
|
||||
draft: null,
|
||||
error: null,
|
||||
});
|
||||
};
|
||||
|
||||
handleDraftChange = (nextDraft) => {
|
||||
this.setState({ draft: nextDraft, error: null });
|
||||
};
|
||||
|
||||
handleSave = async (event) => {
|
||||
event.stopPropagation();
|
||||
if (!this.canSave()) return;
|
||||
|
||||
const { onSave } = this.props;
|
||||
const { draft } = this.state;
|
||||
this.setState({ saving: true, error: null });
|
||||
|
||||
try {
|
||||
await onSave(draft);
|
||||
this.setState({ editing: false, draft: null, saving: false, error: null });
|
||||
enqueueSnackbar('Gespeichert.', { variant: 'success' });
|
||||
} catch (err) {
|
||||
const normalized = backendErrorFrom(err);
|
||||
this.setState({ saving: false, error: normalized.message });
|
||||
enqueueSnackbar(normalized.message, { variant: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
renderTitle() {
|
||||
const { title } = this.props;
|
||||
const { editing } = this.state;
|
||||
if (!title) return null;
|
||||
return (
|
||||
<Typography
|
||||
variant="overline"
|
||||
color="text.secondary"
|
||||
sx={{ display: 'block', mb: 1, pr: editing ? 7 : 4 }}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
renderActions() {
|
||||
const { onSave } = this.props;
|
||||
if (!onSave) return null;
|
||||
|
||||
const { editing, saving } = this.state;
|
||||
const actionSx = {
|
||||
position: 'absolute',
|
||||
top: 4,
|
||||
color: 'text.secondary',
|
||||
};
|
||||
|
||||
if (!editing) {
|
||||
return (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={this.handleStartEdit}
|
||||
aria-label="Bearbeiten"
|
||||
sx={{ ...actionSx, right: 4 }}
|
||||
>
|
||||
<EditOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={this.handleCancel}
|
||||
disabled={saving}
|
||||
aria-label="Abbrechen"
|
||||
sx={{ ...actionSx, right: 40 }}
|
||||
>
|
||||
<CloseOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={this.handleSave}
|
||||
disabled={!this.canSave()}
|
||||
aria-label="Speichern"
|
||||
color={this.canSave() ? 'primary' : 'default'}
|
||||
sx={{ ...actionSx, right: 4 }}
|
||||
>
|
||||
{saving ? (
|
||||
<CircularProgress size={18} color="inherit" />
|
||||
) : (
|
||||
<SaveOutlinedIcon fontSize="small" />
|
||||
)}
|
||||
</IconButton>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
wide = 1,
|
||||
sx,
|
||||
minHeight = 120,
|
||||
renderDisplay,
|
||||
renderEdit,
|
||||
} = this.props;
|
||||
const { editing, draft, error } = this.state;
|
||||
const savedValue = this.getSavedValue();
|
||||
const pending = this.isDirty();
|
||||
|
||||
return (
|
||||
<Paper
|
||||
variant="outlined"
|
||||
sx={panelTilePaperSx({ wide, sx, minHeight, pending })}
|
||||
>
|
||||
{this.renderActions()}
|
||||
{this.renderTitle()}
|
||||
<Box sx={{ flex: 1, minWidth: 0, pr: editing ? 1 : 0 }}>
|
||||
{editing && draft != null
|
||||
? renderEdit(draft, this.handleDraftChange, this.inputRef)
|
||||
: renderDisplay(savedValue)}
|
||||
</Box>
|
||||
{error ? (
|
||||
<Alert severity="error" sx={{ mt: 1, py: 0 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
) : null}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
}
|
||||
56
src/components/panels/PanelTile.jsx
Normal file
56
src/components/panels/PanelTile.jsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { Component } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { GRID_COLUMNS, panelTilePaperSx } from './panelTileLayout';
|
||||
|
||||
export class PanelTile extends Component {
|
||||
renderTitle() {
|
||||
const { title } = this.props;
|
||||
if (!title) return null;
|
||||
return (
|
||||
<Typography
|
||||
variant="overline"
|
||||
color="text.secondary"
|
||||
sx={{ display: 'block', mb: 1 }}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { wide = 1, children, sx, minHeight = 120 } = this.props;
|
||||
|
||||
return (
|
||||
<Paper
|
||||
variant="outlined"
|
||||
sx={panelTilePaperSx({ wide, sx, minHeight })}
|
||||
>
|
||||
{this.renderTitle()}
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>{children}</Box>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function PanelTileGrid({ children, sx }) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: {
|
||||
xs: '1fr',
|
||||
sm: 'repeat(2, 1fr)',
|
||||
md: `repeat(${GRID_COLUMNS}, 1fr)`,
|
||||
},
|
||||
gap: 2,
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export { GRID_COLUMNS };
|
||||
3
src/components/panels/index.js
Normal file
3
src/components/panels/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
export { PanelTile, PanelTileGrid, GRID_COLUMNS } from './PanelTile';
|
||||
export { EditablePanelTile } from './EditablePanelTile';
|
||||
export { PENDING_EDIT_BORDER } from './panelTileLayout';
|
||||
37
src/components/panels/panelTileLayout.js
Normal file
37
src/components/panels/panelTileLayout.js
Normal file
@@ -0,0 +1,37 @@
|
||||
const GRID_COLUMNS = 4;
|
||||
|
||||
export const PENDING_EDIT_BORDER = '#ec4899';
|
||||
|
||||
function clampWide(wide) {
|
||||
return Math.min(Math.max(wide, 1), 3);
|
||||
}
|
||||
|
||||
export function responsiveColumnSpan(wide) {
|
||||
const span = clampWide(wide);
|
||||
return {
|
||||
xs: '1 / -1',
|
||||
sm: span >= 2 ? 'span 2' : 'span 1',
|
||||
md: `span ${span}`,
|
||||
};
|
||||
}
|
||||
|
||||
export const PENDING_EDIT_RING_PX = 4;
|
||||
|
||||
export function panelTilePaperSx({ wide, sx, minHeight = 120, pending = false }) {
|
||||
return {
|
||||
gridColumn: responsiveColumnSpan(wide),
|
||||
position: 'relative',
|
||||
p: 2,
|
||||
minHeight,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
...(pending
|
||||
? {
|
||||
boxShadow: `inset 0 0 0 ${PENDING_EDIT_RING_PX}px ${PENDING_EDIT_BORDER}`,
|
||||
}
|
||||
: {}),
|
||||
...sx,
|
||||
};
|
||||
}
|
||||
|
||||
export { GRID_COLUMNS };
|
||||
@@ -2,6 +2,7 @@ import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { Tile } from './Tile';
|
||||
import { formatPrice } from '../../utils/price';
|
||||
import { articleSku } from '../../pages/artikel/articleRoute';
|
||||
|
||||
export class ArticleTile extends Tile {
|
||||
renderIcon() {
|
||||
@@ -11,14 +12,17 @@ export class ArticleTile extends Tile {
|
||||
renderSecondary() {
|
||||
const { article } = this.props;
|
||||
if (!article) return null;
|
||||
const sku = articleSku(article);
|
||||
return (
|
||||
<>
|
||||
<Typography variant="caption" color="text.secondary" noWrap sx={{ maxWidth: '100%' }}>
|
||||
{article.sku}
|
||||
</Typography>
|
||||
{article.price != null && (
|
||||
{sku ? (
|
||||
<Typography variant="caption" color="text.secondary" noWrap sx={{ maxWidth: '100%' }}>
|
||||
{sku}
|
||||
</Typography>
|
||||
) : null}
|
||||
{article.net != null && (
|
||||
<Typography variant="caption" fontWeight={600}>
|
||||
{formatPrice(article.price)}
|
||||
{formatPrice(article.net)}
|
||||
</Typography>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Component } from 'react';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import ButtonBase from '@mui/material/ButtonBase';
|
||||
import Typography from '@mui/material/Typography';
|
||||
|
||||
export const TILE_SIZE = 140;
|
||||
@@ -55,8 +55,7 @@ export class Tile extends Component {
|
||||
render() {
|
||||
const { sx, disabled } = this.props;
|
||||
return (
|
||||
<Paper
|
||||
variant="outlined"
|
||||
<ButtonBase
|
||||
component="button"
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
@@ -66,6 +65,8 @@ export class Tile extends Component {
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
bgcolor: 'background.paper',
|
||||
borderRadius: 1,
|
||||
overflow: 'hidden',
|
||||
...(disabled
|
||||
? { opacity: 0.5, pointerEvents: 'none' }
|
||||
: {}),
|
||||
@@ -75,7 +76,7 @@ export class Tile extends Component {
|
||||
{this.renderIcon()}
|
||||
{this.renderLabel()}
|
||||
{this.renderSecondary()}
|
||||
</Paper>
|
||||
</ButtonBase>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user