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
|
||||
* 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,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 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() {
|
||||
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,
|
||||
|
||||
@@ -15,6 +15,9 @@ export function photoFromUploadedImage(image) {
|
||||
return {
|
||||
sort: image.sort,
|
||||
fullHash: image.fullHash,
|
||||
fullW: image.full.w,
|
||||
fullH: image.full.h,
|
||||
fullSize: image.full.size,
|
||||
thumbW: image.thumb.w,
|
||||
thumbH: image.thumb.h,
|
||||
thumbHash: image.thumbHash,
|
||||
@@ -23,12 +26,36 @@ export function photoFromUploadedImage(image) {
|
||||
|
||||
export function articleWithUploadedPhoto(article, 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.sort((a, b) => a.sort - b.sort);
|
||||
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) {
|
||||
if (!Array.isArray(article?.prices) || article.prices.length === 0) return null;
|
||||
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 [];
|
||||
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