u
This commit is contained in:
@@ -26,7 +26,7 @@ class ArticlePhoto extends Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
const { photo } = this.props;
|
||||
const { photo, size = 200 } = this.props;
|
||||
const { thumbLoaded } = this.state;
|
||||
const fullHash = photo?.fullHash;
|
||||
const hasPreview = photo?.thumbHash != null;
|
||||
@@ -39,8 +39,8 @@ class ArticlePhoto extends Component {
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
width: 200,
|
||||
height: 200,
|
||||
width: size,
|
||||
height: size,
|
||||
maxWidth: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
@@ -48,6 +48,7 @@ class ArticlePhoto extends Component {
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
bgcolor: 'rgba(0, 0, 0, 0.35)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{hasPreview && !thumbLoaded ? (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { Component, createRef } from 'react';
|
||||
import ContentPasteIcon from '@mui/icons-material/ContentPaste';
|
||||
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
|
||||
import { article } from '../../api';
|
||||
import { PanelTile } from '../panels';
|
||||
import { requireObject } from '../../utils/apiShape';
|
||||
@@ -8,22 +9,23 @@ import { prepareArticlePhoto } from '../../utils/prepareArticlePhoto';
|
||||
import { pasteImageOrFallback } from '../../utils/pasteImage';
|
||||
import { articleWithUploadedPhoto, primaryPhoto } from '../../utils/articleDetail';
|
||||
import ArticlePhoto from './ArticlePhoto';
|
||||
import ArticlePhotosDialog from './ArticlePhotosDialog';
|
||||
|
||||
export default function ArticlePhotoTile({
|
||||
article: articleData,
|
||||
onArticleUpdated,
|
||||
wide = 1,
|
||||
minHeight = 200,
|
||||
sx,
|
||||
tileKey,
|
||||
}) {
|
||||
const fileInputRef = useRef(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const photo = primaryPhoto(articleData);
|
||||
const hasPhoto = photo?.thumbHash != null;
|
||||
class ArticlePhotoTile extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.fileInputRef = createRef();
|
||||
this.state = { busy: false, editOpen: false };
|
||||
this.handlePasteClick = this.handlePasteClick.bind(this);
|
||||
this.handleFileChange = this.handleFileChange.bind(this);
|
||||
this.handleEditOpen = this.handleEditOpen.bind(this);
|
||||
this.handleEditClose = this.handleEditClose.bind(this);
|
||||
this.uploadRawImage = this.uploadRawImage.bind(this);
|
||||
}
|
||||
|
||||
const uploadRawImage = async (rawBlob) => {
|
||||
setBusy(true);
|
||||
async uploadRawImage(rawBlob) {
|
||||
const { article: articleData, onArticleUpdated } = this.props;
|
||||
this.setState({ busy: true });
|
||||
try {
|
||||
const file = await prepareArticlePhoto(rawBlob);
|
||||
const res = await article.uploadImage(articleData.sku, file);
|
||||
@@ -33,53 +35,88 @@ export default function ArticlePhotoTile({
|
||||
} catch (err) {
|
||||
notifyError(err);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
this.setState({ busy: false });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handlePasteClick = () => {
|
||||
if (busy) return;
|
||||
handlePasteClick() {
|
||||
if (this.state.busy) return;
|
||||
void pasteImageOrFallback(() => {
|
||||
fileInputRef.current?.click();
|
||||
this.fileInputRef.current?.click();
|
||||
}).then((image) => {
|
||||
if (image) void uploadRawImage(image);
|
||||
if (image) void this.uploadRawImage(image);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
const handleFileChange = (event) => {
|
||||
handleFileChange(event) {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = '';
|
||||
if (!file) return;
|
||||
void uploadRawImage(file);
|
||||
};
|
||||
void this.uploadRawImage(file);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PanelTile
|
||||
wide={wide}
|
||||
minHeight={minHeight}
|
||||
sx={sx}
|
||||
tileKey={tileKey}
|
||||
action={
|
||||
hasPhoto
|
||||
? undefined
|
||||
: {
|
||||
label: 'Bild einfügen',
|
||||
icon: ContentPasteIcon,
|
||||
onClick: handlePasteClick,
|
||||
busy,
|
||||
}
|
||||
}
|
||||
>
|
||||
<ArticlePhoto photo={photo} />
|
||||
</PanelTile>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
hidden
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
handleEditOpen() {
|
||||
this.setState({ editOpen: true });
|
||||
}
|
||||
|
||||
handleEditClose() {
|
||||
this.setState({ editOpen: false });
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
article: articleData,
|
||||
onArticleUpdated,
|
||||
wide = 1,
|
||||
minHeight = 200,
|
||||
sx,
|
||||
tileKey,
|
||||
} = this.props;
|
||||
const { busy, editOpen } = this.state;
|
||||
const photo = primaryPhoto(articleData);
|
||||
const hasPhoto = photo?.thumbHash != null || photo?.fullHash != null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PanelTile
|
||||
wide={wide}
|
||||
minHeight={minHeight}
|
||||
sx={sx}
|
||||
tileKey={tileKey}
|
||||
action={
|
||||
hasPhoto
|
||||
? {
|
||||
label: 'Bilder bearbeiten',
|
||||
icon: EditOutlinedIcon,
|
||||
onClick: this.handleEditOpen,
|
||||
busy: false,
|
||||
}
|
||||
: {
|
||||
label: 'Bild einfügen',
|
||||
icon: ContentPasteIcon,
|
||||
onClick: this.handlePasteClick,
|
||||
busy,
|
||||
}
|
||||
}
|
||||
>
|
||||
<ArticlePhoto photo={photo} />
|
||||
</PanelTile>
|
||||
<input
|
||||
ref={this.fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
hidden
|
||||
onChange={this.handleFileChange}
|
||||
/>
|
||||
<ArticlePhotosDialog
|
||||
open={editOpen}
|
||||
article={articleData}
|
||||
onClose={this.handleEditClose}
|
||||
onArticleUpdated={onArticleUpdated}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default ArticlePhotoTile;
|
||||
|
||||
270
src/components/article/ArticlePhotosDialog.jsx
Normal file
270
src/components/article/ArticlePhotosDialog.jsx
Normal file
@@ -0,0 +1,270 @@
|
||||
import { Component, createRef } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import Chip from '@mui/material/Chip';
|
||||
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 IconButton from '@mui/material/IconButton';
|
||||
import ContentPasteIcon from '@mui/icons-material/ContentPaste';
|
||||
import DeleteOutlineOutlinedIcon from '@mui/icons-material/DeleteOutlineOutlined';
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
|
||||
import { article } from '../../api';
|
||||
import { requireArray, requireObject } from '../../utils/apiShape';
|
||||
import {
|
||||
articleWithPhotoOrder,
|
||||
articleWithUploadedPhoto,
|
||||
articleWithoutPhoto,
|
||||
formatPhotoDimensions,
|
||||
formatPhotoSizeKb,
|
||||
sortedPhotos,
|
||||
} from '../../utils/articleDetail';
|
||||
import { notifyError } from '../../utils/notify';
|
||||
import { pasteImageOrFallback } from '../../utils/pasteImage';
|
||||
import { prepareArticlePhoto } from '../../utils/prepareArticlePhoto';
|
||||
import ArticlePhoto from './ArticlePhoto';
|
||||
|
||||
const overlayChipSx = {
|
||||
height: 22,
|
||||
bgcolor: 'rgba(0, 0, 0, 0.65)',
|
||||
color: '#fff',
|
||||
'& .MuiChip-label': { px: 0.75, fontSize: 11 },
|
||||
};
|
||||
|
||||
const overlayIconSx = {
|
||||
color: '#fff',
|
||||
bgcolor: 'rgba(0, 0, 0, 0.55)',
|
||||
'&:hover': { bgcolor: 'rgba(0, 0, 0, 0.75)' },
|
||||
'&.Mui-disabled': { color: 'rgba(255,255,255,0.35)', bgcolor: 'rgba(0,0,0,0.35)' },
|
||||
};
|
||||
|
||||
class ArticlePhotosDialog extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.fileInputRef = createRef();
|
||||
this.state = { busy: false };
|
||||
this.handleClose = this.handleClose.bind(this);
|
||||
this.handlePasteClick = this.handlePasteClick.bind(this);
|
||||
this.handleFileChange = this.handleFileChange.bind(this);
|
||||
this.uploadRawImage = this.uploadRawImage.bind(this);
|
||||
}
|
||||
|
||||
handleClose() {
|
||||
if (this.state.busy) return;
|
||||
this.props.onClose();
|
||||
}
|
||||
|
||||
async uploadRawImage(rawBlob) {
|
||||
const { article: articleData, onArticleUpdated } = this.props;
|
||||
this.setState({ busy: true });
|
||||
try {
|
||||
const file = await prepareArticlePhoto(rawBlob);
|
||||
const res = await article.uploadImage(articleData.sku, file);
|
||||
requireObject(res, 'POST /api/article/:sku/image');
|
||||
requireObject(res.image, 'image');
|
||||
onArticleUpdated?.(articleWithUploadedPhoto(articleData, res.image));
|
||||
} catch (err) {
|
||||
notifyError(err);
|
||||
} finally {
|
||||
this.setState({ busy: false });
|
||||
}
|
||||
}
|
||||
|
||||
handlePasteClick() {
|
||||
if (this.state.busy) return;
|
||||
void pasteImageOrFallback(() => {
|
||||
this.fileInputRef.current?.click();
|
||||
}).then((image) => {
|
||||
if (image) void this.uploadRawImage(image);
|
||||
});
|
||||
}
|
||||
|
||||
handleFileChange(event) {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = '';
|
||||
if (!file) return;
|
||||
void this.uploadRawImage(file);
|
||||
}
|
||||
|
||||
async deletePhoto(fullHash) {
|
||||
const { article: articleData, onArticleUpdated } = this.props;
|
||||
this.setState({ busy: true });
|
||||
try {
|
||||
await article.deleteImage(articleData.sku, fullHash);
|
||||
onArticleUpdated?.(articleWithoutPhoto(articleData, fullHash));
|
||||
} catch (err) {
|
||||
notifyError(err);
|
||||
} finally {
|
||||
this.setState({ busy: false });
|
||||
}
|
||||
}
|
||||
|
||||
async movePhoto(fullHash, delta) {
|
||||
const { article: articleData, onArticleUpdated } = this.props;
|
||||
const photos = sortedPhotos(articleData);
|
||||
const index = photos.findIndex((row) => row.fullHash === fullHash);
|
||||
const target = index + delta;
|
||||
if (index < 0 || target < 0 || target >= photos.length) return;
|
||||
|
||||
const next = [...photos];
|
||||
const [row] = next.splice(index, 1);
|
||||
next.splice(target, 0, row);
|
||||
const fullHashes = next.map((item) => item.fullHash);
|
||||
|
||||
this.setState({ busy: true });
|
||||
try {
|
||||
const res = await article.orderImages(articleData.sku, fullHashes);
|
||||
requireObject(res, 'PUT /api/article/:sku/images/order');
|
||||
requireArray(res.fullHashes, 'fullHashes');
|
||||
onArticleUpdated?.(articleWithPhotoOrder(articleData, res.fullHashes));
|
||||
} catch (err) {
|
||||
notifyError(err);
|
||||
} finally {
|
||||
this.setState({ busy: false });
|
||||
}
|
||||
}
|
||||
|
||||
renderPhotoTile(photo, index, total) {
|
||||
const { busy } = this.state;
|
||||
const hasDimensions = photo.fullW != null && photo.fullH != null;
|
||||
const hasSize = photo.fullSize != null;
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={photo.fullHash}
|
||||
sx={{ position: 'relative', width: 200, height: 200, flexShrink: 0 }}
|
||||
>
|
||||
<ArticlePhoto photo={photo} size={200} />
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
pointerEvents: 'none',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
p: 0.75,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 0.5,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
{hasDimensions ? (
|
||||
<Chip
|
||||
size="small"
|
||||
label={formatPhotoDimensions(photo.fullW, photo.fullH)}
|
||||
sx={overlayChipSx}
|
||||
/>
|
||||
) : null}
|
||||
{hasSize ? (
|
||||
<Chip
|
||||
size="small"
|
||||
label={formatPhotoSizeKb(photo.fullSize)}
|
||||
sx={overlayChipSx}
|
||||
/>
|
||||
) : null}
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: 0.25,
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
type="button"
|
||||
size="small"
|
||||
aria-label="Nach vorne"
|
||||
disabled={busy || index === 0}
|
||||
onClick={() => void this.movePhoto(photo.fullHash, -1)}
|
||||
sx={overlayIconSx}
|
||||
>
|
||||
<KeyboardArrowUpIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
type="button"
|
||||
size="small"
|
||||
aria-label="Nach hinten"
|
||||
disabled={busy || index >= total - 1}
|
||||
onClick={() => void this.movePhoto(photo.fullHash, 1)}
|
||||
sx={overlayIconSx}
|
||||
>
|
||||
<KeyboardArrowDownIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
type="button"
|
||||
size="small"
|
||||
aria-label="Löschen"
|
||||
disabled={busy}
|
||||
onClick={() => void this.deletePhoto(photo.fullHash)}
|
||||
sx={overlayIconSx}
|
||||
>
|
||||
<DeleteOutlineOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { open, article: articleData } = this.props;
|
||||
const { busy } = this.state;
|
||||
const photos = sortedPhotos(articleData);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onClose={this.handleClose} maxWidth="md" fullWidth>
|
||||
<DialogTitle>Bilder</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
{photos.length === 0 ? (
|
||||
<Box sx={{ color: 'text.secondary', py: 2 }}>Keine Bilder.</Box>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 1.5,
|
||||
}}
|
||||
>
|
||||
{photos.map((photo, index) =>
|
||||
this.renderPhotoTile(photo, index, photos.length),
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, py: 2, justifyContent: 'space-between' }}>
|
||||
<Button
|
||||
type="button"
|
||||
startIcon={<ContentPasteIcon />}
|
||||
onClick={this.handlePasteClick}
|
||||
disabled={busy}
|
||||
>
|
||||
Bild einfügen
|
||||
</Button>
|
||||
<Button type="button" onClick={this.handleClose} disabled={busy}>
|
||||
Schließen
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
<input
|
||||
ref={this.fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
hidden
|
||||
onChange={this.handleFileChange}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default ArticlePhotosDialog;
|
||||
294
src/components/article/ArticleTileTab.jsx
Normal file
294
src/components/article/ArticleTileTab.jsx
Normal file
@@ -0,0 +1,294 @@
|
||||
import { Component } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import Tab from '@mui/material/Tab';
|
||||
import Tabs from '@mui/material/Tabs';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import AccountTreeIcon from '@mui/icons-material/AccountTree';
|
||||
import CategoryIcon from '@mui/icons-material/Category';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import HistoryIcon from '@mui/icons-material/History';
|
||||
import LabelIcon from '@mui/icons-material/Label';
|
||||
import LocalShippingIcon from '@mui/icons-material/LocalShipping';
|
||||
import ManageSearchIcon from '@mui/icons-material/ManageSearch';
|
||||
import SettingsIcon from '@mui/icons-material/Settings';
|
||||
import { article } from '../../api';
|
||||
import { notifyError } from '../../utils/notify';
|
||||
import { PanelTile } from '../panels';
|
||||
import { PANEL_TILE_BORDER_RADIUS, responsiveColumnSpan } from '../panels/panelTileLayout';
|
||||
|
||||
const TAB = {
|
||||
history: 'history',
|
||||
supplier: 'supplier',
|
||||
seo: 'seo',
|
||||
label: 'label',
|
||||
attributes: 'attributes',
|
||||
compound: 'compound',
|
||||
settings: 'settings',
|
||||
};
|
||||
|
||||
const ALWAYS_TABS = [
|
||||
{ id: TAB.history, label: 'History', color: '#fed7aa', Icon: HistoryIcon },
|
||||
{ id: TAB.supplier, label: 'Supplier', color: '#bbf7d0', Icon: LocalShippingIcon },
|
||||
{ id: TAB.seo, label: 'SEO & Beschreibung', color: '#99f6e4', Icon: ManageSearchIcon },
|
||||
{ id: TAB.label, label: 'Label', color: '#fde68a', Icon: LabelIcon },
|
||||
{ id: TAB.attributes, label: 'Attribute und Merkmale', color: '#bfdbfe', Icon: CategoryIcon },
|
||||
];
|
||||
|
||||
const COMPOUND_TAB = {
|
||||
id: TAB.compound,
|
||||
label: 'Compound Editor',
|
||||
color: '#e9d5ff',
|
||||
Icon: AccountTreeIcon,
|
||||
};
|
||||
|
||||
const SETTINGS_TAB = {
|
||||
id: TAB.settings,
|
||||
label: 'Einstellungen',
|
||||
color: '#fca5a5',
|
||||
Icon: SettingsIcon,
|
||||
};
|
||||
|
||||
function isCompoundArticle(article) {
|
||||
return (
|
||||
article?.articleListCount > 0 ||
|
||||
article?.optionsCount > 0 ||
|
||||
article?.childCount > 0 ||
|
||||
article?.fatherArticleId != null
|
||||
);
|
||||
}
|
||||
|
||||
function visibleTabs(article) {
|
||||
const tabs = [...ALWAYS_TABS];
|
||||
if (isCompoundArticle(article)) {
|
||||
tabs.push(COMPOUND_TAB);
|
||||
}
|
||||
tabs.push(SETTINGS_TAB);
|
||||
return tabs;
|
||||
}
|
||||
|
||||
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 Placeholder({ label }) {
|
||||
return (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{label} — noch nicht verfügbar.
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
class ArticleTileTab extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { tab: TAB.seo, busy: false };
|
||||
this.handleTabChange = this.handleTabChange.bind(this);
|
||||
this.handleDeleteArticle = this.handleDeleteArticle.bind(this);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (prevProps.article === this.props.article) return;
|
||||
const tabs = visibleTabs(this.props.article);
|
||||
if (!tabs.some((row) => row.id === this.state.tab)) {
|
||||
this.setState({ tab: TAB.seo });
|
||||
}
|
||||
}
|
||||
|
||||
handleTabChange(_event, tab) {
|
||||
this.setState({ tab });
|
||||
}
|
||||
|
||||
async handleDeleteArticle() {
|
||||
const { article: articleData, onArticleDeleted } = this.props;
|
||||
if (this.state.busy) return;
|
||||
if (!window.confirm(`Artikel „${articleData.name ?? articleData.sku}“ wirklich löschen?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ busy: true });
|
||||
try {
|
||||
await article.delete(articleData.sku);
|
||||
onArticleDeleted?.(articleData);
|
||||
} catch (err) {
|
||||
notifyError(err);
|
||||
this.setState({ busy: false });
|
||||
}
|
||||
}
|
||||
|
||||
renderSeo() {
|
||||
const { article: articleData } = this.props;
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
renderSettings() {
|
||||
const { busy } = this.state;
|
||||
return (
|
||||
<Box>
|
||||
<Button
|
||||
type="button"
|
||||
color="error"
|
||||
variant="contained"
|
||||
startIcon={<DeleteIcon />}
|
||||
disabled={busy}
|
||||
onClick={() => void this.handleDeleteArticle()}
|
||||
>
|
||||
Artikel löschen
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
renderPanel() {
|
||||
switch (this.state.tab) {
|
||||
case TAB.history:
|
||||
return <Placeholder label="History" />;
|
||||
case TAB.supplier:
|
||||
return <Placeholder label="Supplier" />;
|
||||
case TAB.seo:
|
||||
return this.renderSeo();
|
||||
case TAB.label:
|
||||
return <Placeholder label="Label" />;
|
||||
case TAB.attributes:
|
||||
return <Placeholder label="Attribute und Merkmale" />;
|
||||
case TAB.compound:
|
||||
return <Placeholder label="Compound Editor" />;
|
||||
case TAB.settings:
|
||||
return this.renderSettings();
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { article: articleData, wide = 3, minHeight = 200, sx, tileKey } = this.props;
|
||||
const tabs = visibleTabs(articleData);
|
||||
const { tab } = this.state;
|
||||
const active = tabs.find((row) => row.id === tab) ?? ALWAYS_TABS.find((row) => row.id === TAB.seo);
|
||||
const activeColor = active?.color;
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
gridColumn: responsiveColumnSpan(wide),
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minWidth: 0,
|
||||
filter:
|
||||
'drop-shadow(0px 2px 4px rgba(0,0,0,0.2)) drop-shadow(0px 4px 5px rgba(0,0,0,0.14)) drop-shadow(0px 1px 10px rgba(0,0,0,0.12))',
|
||||
}}
|
||||
>
|
||||
<Tabs
|
||||
value={tab}
|
||||
onChange={this.handleTabChange}
|
||||
variant="standard"
|
||||
TabIndicatorProps={{ sx: { display: 'none', height: 0 } }}
|
||||
sx={{
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
minHeight: 36,
|
||||
px: 0,
|
||||
mb: 0,
|
||||
borderBottom: 'none',
|
||||
overflow: 'visible',
|
||||
'& .MuiTabs-scroller': {
|
||||
px: 0,
|
||||
borderBottom: 'none',
|
||||
overflow: 'visible !important',
|
||||
},
|
||||
'& .MuiTabs-list, & .MuiTabs-flexContainer': {
|
||||
flexWrap: 'wrap-reverse',
|
||||
columnGap: 0.75,
|
||||
rowGap: 0,
|
||||
alignItems: 'stretch',
|
||||
alignContent: 'flex-end',
|
||||
},
|
||||
'& .MuiTabs-indicator': { display: 'none' },
|
||||
'& .MuiTab-root': {
|
||||
flexShrink: 0,
|
||||
maxWidth: 'none',
|
||||
minHeight: 40,
|
||||
marginBottom: 0,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{tabs.map((row) => {
|
||||
const selected = tab === row.id;
|
||||
const { Icon } = row;
|
||||
return (
|
||||
<Tab
|
||||
key={row.id}
|
||||
value={row.id}
|
||||
aria-label={row.label}
|
||||
title={row.label}
|
||||
icon={<Icon fontSize="small" />}
|
||||
sx={{
|
||||
minHeight: 40,
|
||||
minWidth: 48,
|
||||
py: 0.5,
|
||||
px: 1.25,
|
||||
border: 'none',
|
||||
borderBottom: 'none',
|
||||
borderRadius: '8px 8px 0 0',
|
||||
textTransform: 'none',
|
||||
bgcolor: row.color,
|
||||
opacity: selected ? 1 : 0.72,
|
||||
color: 'text.primary',
|
||||
zIndex: selected ? 2 : 1,
|
||||
boxShadow: 'none',
|
||||
'&.Mui-selected': {
|
||||
color: 'text.primary',
|
||||
borderBottom: 'none',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Tabs>
|
||||
<PanelTile
|
||||
wide={wide}
|
||||
minHeight={minHeight}
|
||||
tileKey={tileKey}
|
||||
elevation={0}
|
||||
sx={{
|
||||
gridColumn: 'auto',
|
||||
width: '100%',
|
||||
bgcolor: activeColor,
|
||||
borderTopLeftRadius: 0,
|
||||
borderTopRightRadius: (theme) =>
|
||||
`${(theme.shape.borderRadius || 4) * PANEL_TILE_BORDER_RADIUS}px`,
|
||||
borderBottomLeftRadius: (theme) =>
|
||||
`${(theme.shape.borderRadius || 4) * PANEL_TILE_BORDER_RADIUS}px`,
|
||||
borderBottomRightRadius: (theme) =>
|
||||
`${(theme.shape.borderRadius || 4) * PANEL_TILE_BORDER_RADIUS}px`,
|
||||
boxShadow: 'none',
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
{this.renderPanel()}
|
||||
</PanelTile>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default ArticleTileTab;
|
||||
@@ -1,12 +1,14 @@
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import { Link as RouterLink, useNavigate } from 'react-router-dom';
|
||||
import { useSelector } from 'react-redux';
|
||||
import Box from '@mui/material/Box';
|
||||
import Link from '@mui/material/Link';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import SingleLineTextEdit from '../SingleLineTextEdit';
|
||||
import ArticlePhotoTile from './ArticlePhotoTile';
|
||||
import ArticleTileTab from './ArticleTileTab';
|
||||
import { article } from '../../api';
|
||||
import { EditablePanelTile, PanelTile, PanelTileGrid } from '../panels';
|
||||
import { requireObject } from '../../utils/apiShape';
|
||||
import { requireDefined, requireObject } from '../../utils/apiShape';
|
||||
import {
|
||||
articleEans,
|
||||
brandName,
|
||||
@@ -16,6 +18,11 @@ import {
|
||||
import { formatPrice } from '../../utils/price';
|
||||
import { PANEL_TILE_KEYS } from '../../theme';
|
||||
import { formatStock } from '../../pages/artikel/searchFormat';
|
||||
import {
|
||||
buildCategoryPath,
|
||||
defaultCategoryPath,
|
||||
} from '../../pages/artikel/categoryRoute';
|
||||
import { selectCategoryRootId } from '../../store/categorySlice';
|
||||
|
||||
function PanelValue({ label, children }) {
|
||||
if (children == null || children === '') return null;
|
||||
@@ -99,6 +106,9 @@ const articleNameFieldHiddenSx = {
|
||||
};
|
||||
|
||||
export default function ArticleView({ article: articleData, onArticleUpdated }) {
|
||||
const navigate = useNavigate();
|
||||
const categories = useSelector((state) => state.category.categories);
|
||||
const rootCategoryId = useSelector(selectCategoryRootId);
|
||||
const basePrice = primaryPrice(articleData);
|
||||
const brutto = priceBrutto(basePrice);
|
||||
const eans = articleEans(articleData);
|
||||
@@ -111,6 +121,18 @@ export default function ArticleView({ article: articleData, onArticleUpdated })
|
||||
onArticleUpdated?.(res.article);
|
||||
};
|
||||
|
||||
const handleArticleDeleted = () => {
|
||||
const categoryId = requireDefined(
|
||||
articleData.categoryIds?.[0],
|
||||
'article.categoryIds[0]',
|
||||
);
|
||||
if (categories && rootCategoryId != null) {
|
||||
navigate(buildCategoryPath(categories, rootCategoryId, categoryId));
|
||||
return;
|
||||
}
|
||||
navigate(defaultCategoryPath());
|
||||
};
|
||||
|
||||
return (
|
||||
<PanelTileGrid>
|
||||
<EditablePanelTile
|
||||
@@ -181,12 +203,13 @@ export default function ArticleView({ article: articleData, onArticleUpdated })
|
||||
</PanelValue>
|
||||
</PanelTile>
|
||||
|
||||
<PanelTile wide={3} tileKey={PANEL_TILE_KEYS.content} 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>
|
||||
<ArticleTileTab
|
||||
wide={3}
|
||||
tileKey={PANEL_TILE_KEYS.content}
|
||||
minHeight={200}
|
||||
article={articleData}
|
||||
onArticleDeleted={handleArticleDeleted}
|
||||
/>
|
||||
|
||||
<PanelTile wide={1} tileKey={PANEL_TILE_KEYS.lists} title="Listen" minHeight={200}>
|
||||
<RelationLine
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export { default as ArticleView } from './ArticleView';
|
||||
export { default as ArticleNameDialog } from './ArticleNameDialog';
|
||||
export { default as ArticlePhotosDialog } from './ArticlePhotosDialog';
|
||||
export { default as ArticleTileTab } from './ArticleTileTab';
|
||||
export { default as NewArticleTile } from './NewArticleTile';
|
||||
|
||||
@@ -68,12 +68,21 @@ class PanelTileBase extends Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
const { theme, wide = 1, children, sx, minHeight = 120, tileKey, action } = this.props;
|
||||
const {
|
||||
theme,
|
||||
wide = 1,
|
||||
children,
|
||||
sx,
|
||||
minHeight = 120,
|
||||
tileKey,
|
||||
action,
|
||||
elevation = 4,
|
||||
} = this.props;
|
||||
const actionPadding = action ? TILE_ACTION_PADDING : 0;
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={4}
|
||||
elevation={elevation}
|
||||
sx={panelTilePaperSx({
|
||||
wide,
|
||||
sx,
|
||||
|
||||
Reference in New Issue
Block a user