u
This commit is contained in:
134
README.redux.md
Normal file
134
README.redux.md
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
# Redux in Dashfront
|
||||||
|
|
||||||
|
Übersicht, wie Redux Toolkit bisher im Frontend eingesetzt wird.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- **@reduxjs/toolkit** — Store, Slices, Async Thunks, Listener Middleware
|
||||||
|
- **react-redux** — `Provider` in `src/main.jsx`, Anbindung der UI über `connect()` (kein `useSelector` / `useDispatch`)
|
||||||
|
|
||||||
|
Einstieg:
|
||||||
|
|
||||||
|
```
|
||||||
|
src/main.jsx → <Provider store={store}>
|
||||||
|
src/store/index.js → configureStore + Middleware
|
||||||
|
src/store/*Slice.js → State, Thunks, Selectors
|
||||||
|
```
|
||||||
|
|
||||||
|
## Store-Form
|
||||||
|
|
||||||
|
Drei Slices unter `src/store/`:
|
||||||
|
|
||||||
|
| Key | Datei | Verantwortung |
|
||||||
|
|------------|---------------------|----------------------------------------------------|
|
||||||
|
| `auth` | `authSlice.js` | Session, Login/Logout, Firmenwahl / -anlage |
|
||||||
|
| `company` | `companySlice.js` | Aktuelles Unternehmen laden / aktualisieren |
|
||||||
|
| `category` | `categorySlice.js` | Kategoriebaum, Navigation, Artikel je Kategorie |
|
||||||
|
|
||||||
|
```js
|
||||||
|
// src/store/index.js
|
||||||
|
configureStore({
|
||||||
|
reducer: { auth, company, category },
|
||||||
|
middleware: defaults + listenerMiddleware + (DEV: reduxDevLogger),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Typische Muster
|
||||||
|
|
||||||
|
### Async Thunks als API-Grenze
|
||||||
|
|
||||||
|
Seiten- und Dialog-Logik dispatcht Thunks; die Thunks rufen `src/api` auf und schreiben Ergebnisse in den Slice. Komponenten sprechen die API nicht direkt für diese Domänen an.
|
||||||
|
|
||||||
|
Beispiele:
|
||||||
|
|
||||||
|
- `auth/bootstrap`, `auth/login`, `auth/selectCompany`, `auth/createCompany`, `auth/logout`
|
||||||
|
- `company/loadCurrent`, `company/update`
|
||||||
|
- `category/loadTree`, `category/loadArticles`, `category/create`, `category/createArticle`, `category/delete`
|
||||||
|
|
||||||
|
Fehler in Category-Thunks laufen über `catchBackendError` / `rejectWithValue` und landen als `BackendError` in `state.category.error`. Tree- und Artikel-Responses werden mit `requireObject` / `requireArray` / `requireDefined` aus `apiShape.js` validiert.
|
||||||
|
|
||||||
|
### Selectors
|
||||||
|
|
||||||
|
Jeder Slice exportiert `select…`-Funktionen. UI-Komponenten mappen sie in `mapStateToProps`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const mapStateToProps = (state) => ({
|
||||||
|
status: selectAuthStatus(state),
|
||||||
|
needsCompanySetup: selectNeedsCompanySetup(state),
|
||||||
|
});
|
||||||
|
export default connect(mapStateToProps)(App);
|
||||||
|
```
|
||||||
|
|
||||||
|
Abgeleitete Category-Selectors (Breadcrumbs, Kinder, „kann zurück“) lesen die Map `categories` und `currentCategoryId` — keine zweite Kopie des Baums in der UI.
|
||||||
|
|
||||||
|
### `connect()` statt Hooks
|
||||||
|
|
||||||
|
Die Redux-gebundenen Komponenten sind Class- oder Function-Komponenten mit:
|
||||||
|
|
||||||
|
```js
|
||||||
|
connect(mapStateToProps, mapDispatchToProps)(Component)
|
||||||
|
```
|
||||||
|
|
||||||
|
Dispatch-Props wrappen Thunks, oft mit `.unwrap()` in der Komponente für Erfolg/Fehler (Snackbar).
|
||||||
|
|
||||||
|
### Listener: Auth → Company
|
||||||
|
|
||||||
|
Nach erfolgreichem Bootstrap, Login, Firmenwahl oder Firmenanlage dispatcht ein Listener automatisch `loadCurrentCompany`, wenn `auth.user.selectedCompanyId` gesetzt ist (`companyLoadListener` in `companySlice.js`).
|
||||||
|
|
||||||
|
### Cross-Slice-Reset
|
||||||
|
|
||||||
|
- `logout.fulfilled` leert `company` und setzt `category` auf `initialState`
|
||||||
|
- `loadCurrentCompany.fulfilled` mit neuer `companyId` setzt den Category-State zurück (Firmenwechsel)
|
||||||
|
|
||||||
|
## Datenfluss (Kurz)
|
||||||
|
|
||||||
|
```
|
||||||
|
App-Start
|
||||||
|
└─ AuthBootstrap → bootstrapSession
|
||||||
|
└─ (listener) loadCurrentCompany wenn selectedCompanyId
|
||||||
|
└─ Category-UI → loadCategoryTree (braucht company.rootCategoryId)
|
||||||
|
|
||||||
|
Login / Firmenwahl / -anlage
|
||||||
|
└─ auth-Thunks → user.selectedCompanyId
|
||||||
|
└─ (listener) loadCurrentCompany → ggf. Category-Reset
|
||||||
|
|
||||||
|
Kategorien-Seite
|
||||||
|
└─ navigateToCategory / navigateBack (sync)
|
||||||
|
└─ loadCategoryArticles für Unterkategorien
|
||||||
|
└─ createCategory / createArticle / deleteCategory (optimistisch im Map-/Listen-State)
|
||||||
|
```
|
||||||
|
|
||||||
|
Status-Felder:
|
||||||
|
|
||||||
|
| Slice | Feld(er) | Werte (typisch) |
|
||||||
|
|----------|----------------------------------|------------------------|
|
||||||
|
| auth | `status` | `loading` / `anon` / `authed` |
|
||||||
|
| company | `status` | `idle` / `loading` / `succeeded` / `failed` |
|
||||||
|
| category | `treeStatus`, `articlesStatus` | `idle` / `loading` / `succeeded` / `failed` |
|
||||||
|
|
||||||
|
## Auth-Slice
|
||||||
|
|
||||||
|
- **State:** `user`, `googleClientId`, `status`
|
||||||
|
- **Setup-Gate:** `selectNeedsCompanySetup` → eingeloggt, aber keine `selectedCompanyId` → `CompanySetupDialog`
|
||||||
|
- `mergeUser` behält beim Partial-Update ggf. die bisherige `selectedCompanyId`
|
||||||
|
|
||||||
|
## Company-Slice
|
||||||
|
|
||||||
|
- **State:** `current` (normalisiert über `normalizeCompany`), `status`, `error`
|
||||||
|
- Thunks lesen `auth.user.selectedCompanyId` aus dem Store (`getState`)
|
||||||
|
- Selectors liefern u. a. Name, `iso639`, Steuersätze, `rootCategoryId` für Category/UI
|
||||||
|
|
||||||
|
## Category-Slice
|
||||||
|
|
||||||
|
- **Baum:** `categories` als Id→Node-Map, plus `rootCategoryId`, `rootArticles`, `languageId`
|
||||||
|
- **Navigation:** `currentCategoryId`; Root zeigt `rootArticles`, Unterkategorien laden per `loadCategoryArticles`
|
||||||
|
- **Sync-Actions:** `navigateToCategory`, `navigateBack`, `resetCategoryState`
|
||||||
|
- Hilfsfunktionen (`normalizeCategoryNode`, `parseCategoryTreeResponse`, Breadcrumbs, Slugs) leben im Slice und werden u. a. von `categoryRoute.js` für URL-Pfade wiederverwendet
|
||||||
|
|
||||||
|
## Dev-Logging
|
||||||
|
|
||||||
|
In Development loggt `reduxDevLogger` Actions unter `auth/`, `company/`, `category/` inkl. Payload und kurzem Auth-/Company-Snapshot nach dem Reduce.
|
||||||
|
|
||||||
|
## Was (noch) nicht in Redux liegt
|
||||||
|
|
||||||
|
Artikel-Detail, Fotos und ähnliche lokale UI-Zustände werden in Komponenten / lokalen Props gehalten, nicht als eigener Slice. Neue Domänen folgen dem bestehenden Muster: API in `src/api`, Thunk + Validierung im Slice, Selectors + `connect` in der UI.
|
||||||
@@ -177,6 +177,15 @@ export const article = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/article/:sku → 204
|
||||||
|
*/
|
||||||
|
delete(sku) {
|
||||||
|
return http(`/api/article/${encodeURIComponent(sku)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/article/:sku/image
|
* POST /api/article/:sku/image
|
||||||
* Body: raw AVIF bytes (Content-Type: image/avif, max 1000×1000, prepared client-side via WASM)
|
* Body: raw AVIF bytes (Content-Type: image/avif, max 1000×1000, prepared client-side via WASM)
|
||||||
@@ -189,6 +198,28 @@ export const article = {
|
|||||||
body: file,
|
body: file,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/article/:sku/image/:fullHash → 204
|
||||||
|
*/
|
||||||
|
deleteImage(sku, fullHash) {
|
||||||
|
return http(
|
||||||
|
`/api/article/${encodeURIComponent(sku)}/image/${encodeURIComponent(fullHash)}`,
|
||||||
|
{ method: 'DELETE' },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/article/:sku/images/order
|
||||||
|
* Body: { fullHashes: string[] } (every image exactly once)
|
||||||
|
* → 200 { fullHashes: string[] }
|
||||||
|
*/
|
||||||
|
orderImages(sku, fullHashes) {
|
||||||
|
return http(`/api/article/${encodeURIComponent(sku)}/images/order`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: { fullHashes },
|
||||||
|
});
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class ArticlePhoto extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { photo } = this.props;
|
const { photo, size = 200 } = this.props;
|
||||||
const { thumbLoaded } = this.state;
|
const { thumbLoaded } = this.state;
|
||||||
const fullHash = photo?.fullHash;
|
const fullHash = photo?.fullHash;
|
||||||
const hasPreview = photo?.thumbHash != null;
|
const hasPreview = photo?.thumbHash != null;
|
||||||
@@ -39,8 +39,8 @@ class ArticlePhoto extends Component {
|
|||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
width: 200,
|
width: size,
|
||||||
height: 200,
|
height: size,
|
||||||
maxWidth: '100%',
|
maxWidth: '100%',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -48,6 +48,7 @@ class ArticlePhoto extends Component {
|
|||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
bgcolor: 'rgba(0, 0, 0, 0.35)',
|
bgcolor: 'rgba(0, 0, 0, 0.35)',
|
||||||
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{hasPreview && !thumbLoaded ? (
|
{hasPreview && !thumbLoaded ? (
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useRef, useState } from 'react';
|
import { Component, createRef } from 'react';
|
||||||
import ContentPasteIcon from '@mui/icons-material/ContentPaste';
|
import ContentPasteIcon from '@mui/icons-material/ContentPaste';
|
||||||
|
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
|
||||||
import { article } from '../../api';
|
import { article } from '../../api';
|
||||||
import { PanelTile } from '../panels';
|
import { PanelTile } from '../panels';
|
||||||
import { requireObject } from '../../utils/apiShape';
|
import { requireObject } from '../../utils/apiShape';
|
||||||
@@ -8,22 +9,23 @@ import { prepareArticlePhoto } from '../../utils/prepareArticlePhoto';
|
|||||||
import { pasteImageOrFallback } from '../../utils/pasteImage';
|
import { pasteImageOrFallback } from '../../utils/pasteImage';
|
||||||
import { articleWithUploadedPhoto, primaryPhoto } from '../../utils/articleDetail';
|
import { articleWithUploadedPhoto, primaryPhoto } from '../../utils/articleDetail';
|
||||||
import ArticlePhoto from './ArticlePhoto';
|
import ArticlePhoto from './ArticlePhoto';
|
||||||
|
import ArticlePhotosDialog from './ArticlePhotosDialog';
|
||||||
|
|
||||||
export default function ArticlePhotoTile({
|
class ArticlePhotoTile extends Component {
|
||||||
article: articleData,
|
constructor(props) {
|
||||||
onArticleUpdated,
|
super(props);
|
||||||
wide = 1,
|
this.fileInputRef = createRef();
|
||||||
minHeight = 200,
|
this.state = { busy: false, editOpen: false };
|
||||||
sx,
|
this.handlePasteClick = this.handlePasteClick.bind(this);
|
||||||
tileKey,
|
this.handleFileChange = this.handleFileChange.bind(this);
|
||||||
}) {
|
this.handleEditOpen = this.handleEditOpen.bind(this);
|
||||||
const fileInputRef = useRef(null);
|
this.handleEditClose = this.handleEditClose.bind(this);
|
||||||
const [busy, setBusy] = useState(false);
|
this.uploadRawImage = this.uploadRawImage.bind(this);
|
||||||
const photo = primaryPhoto(articleData);
|
}
|
||||||
const hasPhoto = photo?.thumbHash != null;
|
|
||||||
|
|
||||||
const uploadRawImage = async (rawBlob) => {
|
async uploadRawImage(rawBlob) {
|
||||||
setBusy(true);
|
const { article: articleData, onArticleUpdated } = this.props;
|
||||||
|
this.setState({ busy: true });
|
||||||
try {
|
try {
|
||||||
const file = await prepareArticlePhoto(rawBlob);
|
const file = await prepareArticlePhoto(rawBlob);
|
||||||
const res = await article.uploadImage(articleData.sku, file);
|
const res = await article.uploadImage(articleData.sku, file);
|
||||||
@@ -33,53 +35,88 @@ export default function ArticlePhotoTile({
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
notifyError(err);
|
notifyError(err);
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
this.setState({ busy: false });
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const handlePasteClick = () => {
|
handlePasteClick() {
|
||||||
if (busy) return;
|
if (this.state.busy) return;
|
||||||
void pasteImageOrFallback(() => {
|
void pasteImageOrFallback(() => {
|
||||||
fileInputRef.current?.click();
|
this.fileInputRef.current?.click();
|
||||||
}).then((image) => {
|
}).then((image) => {
|
||||||
if (image) void uploadRawImage(image);
|
if (image) void this.uploadRawImage(image);
|
||||||
});
|
});
|
||||||
};
|
}
|
||||||
|
|
||||||
const handleFileChange = (event) => {
|
handleFileChange(event) {
|
||||||
const file = event.target.files?.[0];
|
const file = event.target.files?.[0];
|
||||||
event.target.value = '';
|
event.target.value = '';
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
void uploadRawImage(file);
|
void this.uploadRawImage(file);
|
||||||
};
|
}
|
||||||
|
|
||||||
return (
|
handleEditOpen() {
|
||||||
<>
|
this.setState({ editOpen: true });
|
||||||
<PanelTile
|
}
|
||||||
wide={wide}
|
|
||||||
minHeight={minHeight}
|
handleEditClose() {
|
||||||
sx={sx}
|
this.setState({ editOpen: false });
|
||||||
tileKey={tileKey}
|
}
|
||||||
action={
|
|
||||||
hasPhoto
|
render() {
|
||||||
? undefined
|
const {
|
||||||
: {
|
article: articleData,
|
||||||
label: 'Bild einfügen',
|
onArticleUpdated,
|
||||||
icon: ContentPasteIcon,
|
wide = 1,
|
||||||
onClick: handlePasteClick,
|
minHeight = 200,
|
||||||
busy,
|
sx,
|
||||||
}
|
tileKey,
|
||||||
}
|
} = this.props;
|
||||||
>
|
const { busy, editOpen } = this.state;
|
||||||
<ArticlePhoto photo={photo} />
|
const photo = primaryPhoto(articleData);
|
||||||
</PanelTile>
|
const hasPhoto = photo?.thumbHash != null || photo?.fullHash != null;
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
return (
|
||||||
type="file"
|
<>
|
||||||
accept="image/*"
|
<PanelTile
|
||||||
hidden
|
wide={wide}
|
||||||
onChange={handleFileChange}
|
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 Box from '@mui/material/Box';
|
||||||
import Link from '@mui/material/Link';
|
import Link from '@mui/material/Link';
|
||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
import SingleLineTextEdit from '../SingleLineTextEdit';
|
import SingleLineTextEdit from '../SingleLineTextEdit';
|
||||||
import ArticlePhotoTile from './ArticlePhotoTile';
|
import ArticlePhotoTile from './ArticlePhotoTile';
|
||||||
|
import ArticleTileTab from './ArticleTileTab';
|
||||||
import { article } from '../../api';
|
import { article } from '../../api';
|
||||||
import { EditablePanelTile, PanelTile, PanelTileGrid } from '../panels';
|
import { EditablePanelTile, PanelTile, PanelTileGrid } from '../panels';
|
||||||
import { requireObject } from '../../utils/apiShape';
|
import { requireDefined, requireObject } from '../../utils/apiShape';
|
||||||
import {
|
import {
|
||||||
articleEans,
|
articleEans,
|
||||||
brandName,
|
brandName,
|
||||||
@@ -16,6 +18,11 @@ import {
|
|||||||
import { formatPrice } from '../../utils/price';
|
import { formatPrice } from '../../utils/price';
|
||||||
import { PANEL_TILE_KEYS } from '../../theme';
|
import { PANEL_TILE_KEYS } from '../../theme';
|
||||||
import { formatStock } from '../../pages/artikel/searchFormat';
|
import { formatStock } from '../../pages/artikel/searchFormat';
|
||||||
|
import {
|
||||||
|
buildCategoryPath,
|
||||||
|
defaultCategoryPath,
|
||||||
|
} from '../../pages/artikel/categoryRoute';
|
||||||
|
import { selectCategoryRootId } from '../../store/categorySlice';
|
||||||
|
|
||||||
function PanelValue({ label, children }) {
|
function PanelValue({ label, children }) {
|
||||||
if (children == null || children === '') return null;
|
if (children == null || children === '') return null;
|
||||||
@@ -99,6 +106,9 @@ const articleNameFieldHiddenSx = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function ArticleView({ article: articleData, onArticleUpdated }) {
|
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 basePrice = primaryPrice(articleData);
|
||||||
const brutto = priceBrutto(basePrice);
|
const brutto = priceBrutto(basePrice);
|
||||||
const eans = articleEans(articleData);
|
const eans = articleEans(articleData);
|
||||||
@@ -111,6 +121,18 @@ export default function ArticleView({ article: articleData, onArticleUpdated })
|
|||||||
onArticleUpdated?.(res.article);
|
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 (
|
return (
|
||||||
<PanelTileGrid>
|
<PanelTileGrid>
|
||||||
<EditablePanelTile
|
<EditablePanelTile
|
||||||
@@ -181,12 +203,13 @@ export default function ArticleView({ article: articleData, onArticleUpdated })
|
|||||||
</PanelValue>
|
</PanelValue>
|
||||||
</PanelTile>
|
</PanelTile>
|
||||||
|
|
||||||
<PanelTile wide={3} tileKey={PANEL_TILE_KEYS.content} title="SEO & Beschreibung" minHeight={200}>
|
<ArticleTileTab
|
||||||
<PanelValue label="Slug">{articleData.slug}</PanelValue>
|
wide={3}
|
||||||
<PanelValue label="SEO-Titel">{articleData.seoTitle}</PanelValue>
|
tileKey={PANEL_TILE_KEYS.content}
|
||||||
<PanelValue label="SEO-Beschreibung">{articleData.seoDescription}</PanelValue>
|
minHeight={200}
|
||||||
<PanelValue label="Beschreibung">{articleData.description}</PanelValue>
|
article={articleData}
|
||||||
</PanelTile>
|
onArticleDeleted={handleArticleDeleted}
|
||||||
|
/>
|
||||||
|
|
||||||
<PanelTile wide={1} tileKey={PANEL_TILE_KEYS.lists} title="Listen" minHeight={200}>
|
<PanelTile wide={1} tileKey={PANEL_TILE_KEYS.lists} title="Listen" minHeight={200}>
|
||||||
<RelationLine
|
<RelationLine
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
export { default as ArticleView } from './ArticleView';
|
export { default as ArticleView } from './ArticleView';
|
||||||
export { default as ArticleNameDialog } from './ArticleNameDialog';
|
export { default as ArticleNameDialog } from './ArticleNameDialog';
|
||||||
|
export { default as ArticlePhotosDialog } from './ArticlePhotosDialog';
|
||||||
|
export { default as ArticleTileTab } from './ArticleTileTab';
|
||||||
export { default as NewArticleTile } from './NewArticleTile';
|
export { default as NewArticleTile } from './NewArticleTile';
|
||||||
|
|||||||
@@ -68,12 +68,21 @@ class PanelTileBase extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
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;
|
const actionPadding = action ? TILE_ACTION_PADDING : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper
|
<Paper
|
||||||
elevation={4}
|
elevation={elevation}
|
||||||
sx={panelTilePaperSx({
|
sx={panelTilePaperSx({
|
||||||
wide,
|
wide,
|
||||||
sx,
|
sx,
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ export function photoFromUploadedImage(image) {
|
|||||||
return {
|
return {
|
||||||
sort: image.sort,
|
sort: image.sort,
|
||||||
fullHash: image.fullHash,
|
fullHash: image.fullHash,
|
||||||
|
fullW: image.full.w,
|
||||||
|
fullH: image.full.h,
|
||||||
|
fullSize: image.full.size,
|
||||||
thumbW: image.thumb.w,
|
thumbW: image.thumb.w,
|
||||||
thumbH: image.thumb.h,
|
thumbH: image.thumb.h,
|
||||||
thumbHash: image.thumbHash,
|
thumbHash: image.thumbHash,
|
||||||
@@ -23,12 +26,36 @@ export function photoFromUploadedImage(image) {
|
|||||||
|
|
||||||
export function articleWithUploadedPhoto(article, image) {
|
export function articleWithUploadedPhoto(article, image) {
|
||||||
const photo = photoFromUploadedImage(image);
|
const photo = photoFromUploadedImage(image);
|
||||||
const photos = sortedPhotos(article).filter((row) => row.sort !== photo.sort);
|
const photos = sortedPhotos(article).filter((row) => row.fullHash !== photo.fullHash);
|
||||||
photos.push(photo);
|
photos.push(photo);
|
||||||
photos.sort((a, b) => a.sort - b.sort);
|
photos.sort((a, b) => a.sort - b.sort);
|
||||||
return { ...article, photos };
|
return { ...article, photos };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** After DELETE /api/article/:sku/image/:fullHash */
|
||||||
|
export function articleWithoutPhoto(article, fullHash) {
|
||||||
|
const photos = sortedPhotos(article)
|
||||||
|
.filter((row) => row.fullHash !== fullHash)
|
||||||
|
.map((row, index) => ({ ...row, sort: index }));
|
||||||
|
return { ...article, photos };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* After PUT /api/article/:sku/images/order → { fullHashes }.
|
||||||
|
* Reorders local photos[] to match; sort = index.
|
||||||
|
*/
|
||||||
|
export function articleWithPhotoOrder(article, fullHashes) {
|
||||||
|
const byHash = new Map(sortedPhotos(article).map((row) => [row.fullHash, row]));
|
||||||
|
const photos = fullHashes.map((fullHash, index) => {
|
||||||
|
const row = byHash.get(fullHash);
|
||||||
|
if (!row) {
|
||||||
|
throw new Error(`photos order: unbekanntes fullHash ${fullHash}`);
|
||||||
|
}
|
||||||
|
return { ...row, sort: index };
|
||||||
|
});
|
||||||
|
return { ...article, photos };
|
||||||
|
}
|
||||||
|
|
||||||
export function primaryPrice(article) {
|
export function primaryPrice(article) {
|
||||||
if (!Array.isArray(article?.prices) || article.prices.length === 0) return null;
|
if (!Array.isArray(article?.prices) || article.prices.length === 0) return null;
|
||||||
return article.prices.find((row) => row.minmultiplier === 1) ?? article.prices[0];
|
return article.prices.find((row) => row.minmultiplier === 1) ?? article.prices[0];
|
||||||
@@ -47,3 +74,11 @@ export function articleEans(article) {
|
|||||||
if (!Array.isArray(article?.eans)) return [];
|
if (!Array.isArray(article?.eans)) return [];
|
||||||
return article.eans.filter(Boolean);
|
return article.eans.filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatPhotoSizeKb(fullSize) {
|
||||||
|
return `${Math.round(fullSize / 1024)} KB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPhotoDimensions(fullW, fullH) {
|
||||||
|
return `${fullW}×${fullH}`;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user