diff --git a/README.redux.md b/README.redux.md new file mode 100644 index 0000000..e8aa275 --- /dev/null +++ b/README.redux.md @@ -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 → +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. diff --git a/src/api/index.js b/src/api/index.js index 17abed9..7b46938 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -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 }, + }); + }, }; /** diff --git a/src/components/article/ArticlePhoto.jsx b/src/components/article/ArticlePhoto.jsx index 764f4ab..560d7d3 100644 --- a/src/components/article/ArticlePhoto.jsx +++ b/src/components/article/ArticlePhoto.jsx @@ -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 { {hasPreview && !thumbLoaded ? ( diff --git a/src/components/article/ArticlePhotoTile.jsx b/src/components/article/ArticlePhotoTile.jsx index 176add8..dab092a 100644 --- a/src/components/article/ArticlePhotoTile.jsx +++ b/src/components/article/ArticlePhotoTile.jsx @@ -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 ( - <> - - - - - - ); + 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 ( + <> + + + + + + + ); + } } + +export default ArticlePhotoTile; diff --git a/src/components/article/ArticlePhotosDialog.jsx b/src/components/article/ArticlePhotosDialog.jsx new file mode 100644 index 0000000..55e6e22 --- /dev/null +++ b/src/components/article/ArticlePhotosDialog.jsx @@ -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 ( + + + + + {hasDimensions ? ( + + ) : null} + {hasSize ? ( + + ) : null} + + + void this.movePhoto(photo.fullHash, -1)} + sx={overlayIconSx} + > + + + = total - 1} + onClick={() => void this.movePhoto(photo.fullHash, 1)} + sx={overlayIconSx} + > + + + void this.deletePhoto(photo.fullHash)} + sx={overlayIconSx} + > + + + + + + ); + } + + render() { + const { open, article: articleData } = this.props; + const { busy } = this.state; + const photos = sortedPhotos(articleData); + + return ( + <> + + Bilder + + {photos.length === 0 ? ( + Keine Bilder. + ) : ( + + {photos.map((photo, index) => + this.renderPhotoTile(photo, index, photos.length), + )} + + )} + + + + + + + + + ); + } +} + +export default ArticlePhotosDialog; diff --git a/src/components/article/ArticleTileTab.jsx b/src/components/article/ArticleTileTab.jsx new file mode 100644 index 0000000..d16f0cd --- /dev/null +++ b/src/components/article/ArticleTileTab.jsx @@ -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 ( + + {label ? ( + + {label} + + ) : null} + + {children} + + + ); +} + +function Placeholder({ label }) { + return ( + + {label} — noch nicht verfügbar. + + ); +} + +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 ( + <> + {articleData.slug} + {articleData.seoTitle} + {articleData.seoDescription} + {articleData.description} + + ); + } + + renderSettings() { + const { busy } = this.state; + return ( + + + + ); + } + + renderPanel() { + switch (this.state.tab) { + case TAB.history: + return ; + case TAB.supplier: + return ; + case TAB.seo: + return this.renderSeo(); + case TAB.label: + return ; + case TAB.attributes: + return ; + case TAB.compound: + return ; + 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 ( + + + {tabs.map((row) => { + const selected = tab === row.id; + const { Icon } = row; + return ( + } + 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', + }, + }} + /> + ); + })} + + + `${(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()} + + + ); + } +} + +export default ArticleTileTab; diff --git a/src/components/article/ArticleView.jsx b/src/components/article/ArticleView.jsx index 3de2b42..f662bae 100644 --- a/src/components/article/ArticleView.jsx +++ b/src/components/article/ArticleView.jsx @@ -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 ( - - {articleData.slug} - {articleData.seoTitle} - {articleData.seoDescription} - {articleData.description} - + 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}`; +}