diff --git a/index.html b/index.html index 0782aac..f805db5 100644 --- a/index.html +++ b/index.html @@ -24,6 +24,29 @@
+ diff --git a/src/api/index.js b/src/api/index.js index 6f1e4dd..a0a1fba 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -65,6 +65,8 @@ async function http(path, { method = 'GET', body } = {}) { logApi('error-body', { method, path, status: res.status, message, data }); const err = new Error(message || `Request failed (${res.status})`); err.status = res.status; + err.path = path; + err.method = method; err.data = data; throw err; } @@ -92,6 +94,30 @@ export const article = { const params = new URLSearchParams({ target }); return http(`/api/article/get?${params}`); }, + + /** + * GET /api/article/byCategory?categoryId&offset&maxCount + * Articles assigned to a category (for tile view). + */ + byCategory({ categoryId, offset, maxCount } = {}) { + const params = new URLSearchParams({ categoryId: String(categoryId) }); + if (offset) params.set('offset', String(offset)); + if (maxCount != null) params.set('maxCount', String(maxCount)); + return http(`/api/article/byCategory?${params}`); + }, +}; + +export const category = { + /** + * GET /api/category/tree?companyId= + * Flat map of categories keyed by id; rootCategoryId resolved server-side. + */ + getTree({ companyId } = {}) { + const params = new URLSearchParams(); + if (companyId != null) params.set('companyId', String(companyId)); + const qs = params.toString(); + return http(`/api/category/tree${qs ? `?${qs}` : ''}`); + }, }; export const company = { @@ -119,14 +145,8 @@ export const company = { }, /** - * Backend suggestion (not implemented yet): - * * PUT /api/company/:companyId * Body: { name, iso639, taxrate, reducedtaxrate } - * Response: { company: { id, name, iso639, taxrate, reducedtaxrate, ... } } - * Auth: session cookie; user must be allowed to edit that company. - * - * Optional: add iso639 to GET /api/company list items (today only languageId). */ update(companyId, { name, iso639, taxrate, reducedtaxrate }) { return http(`/api/company/${companyId}`, { @@ -194,4 +214,4 @@ export const auth = { }, }; -export default { auth, article, company }; +export default { auth, article, category, company }; diff --git a/src/components/AuthBootstrap.jsx b/src/components/AuthBootstrap.jsx index 88571b4..ad4834a 100644 --- a/src/components/AuthBootstrap.jsx +++ b/src/components/AuthBootstrap.jsx @@ -1,10 +1,15 @@ import { Component } from 'react'; import { connect } from 'react-redux'; import { enqueueSnackbar } from 'notistack'; -import { bootstrapSession } from '../store/authSlice'; +import { bootstrapSession as bootstrapSessionThunk } from '../store/authSlice'; + +let sessionBootstrapped = false; class AuthBootstrap extends Component { componentDidMount() { + if (sessionBootstrapped) return; + sessionBootstrapped = true; + this.props .bootstrapSession() .unwrap() @@ -24,7 +29,7 @@ class AuthBootstrap extends Component { } const mapDispatchToProps = (dispatch) => ({ - bootstrapSession: () => dispatch(bootstrapSession()), + bootstrapSession: () => dispatch(bootstrapSessionThunk()), }); export default connect(null, mapDispatchToProps)(AuthBootstrap); diff --git a/src/components/BackendError.jsx b/src/components/BackendError.jsx new file mode 100644 index 0000000..e1f6861 --- /dev/null +++ b/src/components/BackendError.jsx @@ -0,0 +1,55 @@ +import { Component } from 'react'; +import Alert from '@mui/material/Alert'; +import AlertTitle from '@mui/material/AlertTitle'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Typography from '@mui/material/Typography'; +import { formatBackendError } from '../utils/backendError'; + +class BackendError extends Component { + render() { + const { error, title, onRetry, sx, action } = this.props; + if (!error) return null; + + const formatted = formatBackendError(error); + const heading = title || formatted.title; + + return ( + + {heading} + {formatted.detail ? ( + + {formatted.detail} + + ) : null} + {formatted.request ? ( + + {formatted.request} + {formatted.status != null ? ` → ${formatted.status}` : ''} + + ) : null} + {onRetry ? ( + + + + ) : null} + + ); + } +} + +export default BackendError; diff --git a/src/components/CompanySettingsDialog.jsx b/src/components/CompanySettingsDialog.jsx index 254e9e0..1ff38fb 100644 --- a/src/components/CompanySettingsDialog.jsx +++ b/src/components/CompanySettingsDialog.jsx @@ -16,19 +16,21 @@ import TextField from '@mui/material/TextField'; import ToggleButton from '@mui/material/ToggleButton'; import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; import { enqueueSnackbar } from 'notistack'; -import { company } from '../api'; +import BackendError from './BackendError'; import { selectAuthUser } from '../store/authSlice'; import { - ISO639_OPTIONS, - companyLanguageToIso639, - parseTaxRate, -} from '../utils/companyForm'; + loadCurrentCompany, + selectCompanyStatus, + selectCurrentCompany, + updateCompany as updateCompanyThunk, +} from '../store/companySlice'; +import { ISO639_OPTIONS, parseTaxRate } from '../utils/companyForm'; +import { backendErrorFrom, isBackendFailure } from '../utils/backendError'; class CompanySettingsDialog extends Component { constructor(props) { super(props); this.state = { - loading: false, busy: false, error: null, name: '', @@ -37,39 +39,37 @@ class CompanySettingsDialog extends Component { reducedtaxrate: '', }; this.handleSubmit = this.handleSubmit.bind(this); + this.syncFormFromCompany = this.syncFormFromCompany.bind(this); } componentDidUpdate(prevProps) { - const { open, user } = this.props; - const companyId = user?.selectedCompanyId; - if (open && !prevProps.open && companyId) { - void this.loadCompany(companyId); + const { open, company, companyStatus } = this.props; + if (open && !prevProps.open) { + this.setState({ error: null }); + if (company) { + this.syncFormFromCompany(company); + } else if (companyStatus !== 'loading') { + void this.props.loadCurrentCompany(); + } + } + if (open && company && company !== prevProps.company) { + this.syncFormFromCompany(company); } } - async loadCompany(companyId) { - this.setState({ loading: true, error: null }); - try { - const data = await company.getById(companyId); - const item = data.company; - this.setState({ - name: item.name ?? '', - iso639: companyLanguageToIso639(item), - taxrate: item.taxrate != null ? String(item.taxrate) : '', - reducedtaxrate: item.reducedtaxrate != null ? String(item.reducedtaxrate) : '', - }); - } catch (err) { - this.setState({ error: err.message }); - } finally { - this.setState({ loading: false }); - } + syncFormFromCompany(company) { + this.setState({ + name: company.name ?? '', + iso639: company.iso639 ?? 'de', + taxrate: company.taxrate != null ? String(company.taxrate) : '', + reducedtaxrate: company.reducedtaxrate != null ? String(company.reducedtaxrate) : '', + }); } async handleSubmit(event) { event.preventDefault(); - const { user, onClose } = this.props; + const { onClose } = this.props; const { name, iso639, taxrate, reducedtaxrate } = this.state; - const companyId = user?.selectedCompanyId; const parsedTaxrate = parseTaxRate(taxrate); const parsedReducedtaxrate = parseTaxRate(reducedtaxrate); const canSave = @@ -78,31 +78,34 @@ class CompanySettingsDialog extends Component { parsedTaxrate != null && parsedReducedtaxrate != null; - if (!companyId || !canSave) { + if (!canSave) { this.setState({ error: 'Bitte alle Pflichtfelder ausfüllen.' }); return; } this.setState({ busy: true, error: null }); try { - await company.update(companyId, { - name: name.trim(), - iso639, - taxrate: parsedTaxrate, - reducedtaxrate: parsedReducedtaxrate, - }); + await this.props + .updateCompany({ + name: name.trim(), + iso639, + taxrate: parsedTaxrate, + reducedtaxrate: parsedReducedtaxrate, + }) + .unwrap(); enqueueSnackbar('Unternehmen gespeichert.', { variant: 'success' }); onClose(); } catch (err) { - this.setState({ error: err.message }); + this.setState({ error: backendErrorFrom(err) }); } finally { this.setState({ busy: false }); } } render() { - const { open, onClose } = this.props; - const { loading, busy, error, name, iso639, taxrate, reducedtaxrate } = this.state; + const { open, onClose, companyStatus } = this.props; + const { busy, error, name, iso639, taxrate, reducedtaxrate } = this.state; + const loading = companyStatus === 'loading' && !busy; const parsedTaxrate = parseTaxRate(taxrate); const parsedReducedtaxrate = parseTaxRate(reducedtaxrate); const canSave = @@ -117,9 +120,13 @@ class CompanySettingsDialog extends Component { {error ? ( - - {error} - + isBackendFailure(error) ? ( + + ) : ( + + {typeof error === 'string' ? error : error.message} + + ) ) : null} {loading ? ( @@ -212,6 +219,13 @@ class CompanySettingsDialog extends Component { const mapStateToProps = (state) => ({ user: selectAuthUser(state), + company: selectCurrentCompany(state), + companyStatus: selectCompanyStatus(state), }); -export default connect(mapStateToProps)(CompanySettingsDialog); +const mapDispatchToProps = (dispatch) => ({ + loadCurrentCompany: () => dispatch(loadCurrentCompany()), + updateCompany: (payload) => dispatch(updateCompanyThunk(payload)), +}); + +export default connect(mapStateToProps, mapDispatchToProps)(CompanySettingsDialog); diff --git a/src/components/CompanySetupDialog.jsx b/src/components/CompanySetupDialog.jsx index e9a29ff..78e890a 100644 --- a/src/components/CompanySetupDialog.jsx +++ b/src/components/CompanySetupDialog.jsx @@ -20,13 +20,19 @@ import ToggleButton from '@mui/material/ToggleButton'; import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; import Typography from '@mui/material/Typography'; import BusinessIcon from '@mui/icons-material/Business'; +import BackendError from './BackendError'; import { company } from '../api'; -import { createCompany, logout, selectCompany } from '../store/authSlice'; +import { + createCompany as createCompanyThunk, + logout as logoutThunk, + selectCompany as selectCompanyThunk, +} from '../store/authSlice'; import { defaultIso639, ISO639_OPTIONS, parseTaxRate, } from '../utils/companyForm'; +import { backendErrorFrom, isBackendFailure } from '../utils/backendError'; class CompanySetupDialog extends Component { constructor(props) { @@ -44,6 +50,7 @@ class CompanySetupDialog extends Component { this.handleSelect = this.handleSelect.bind(this); this.handleCreate = this.handleCreate.bind(this); this.handleLogout = this.handleLogout.bind(this); + this.handleClose = this.handleClose.bind(this); } componentDidMount() { @@ -67,7 +74,7 @@ class CompanySetupDialog extends Component { try { await this.props.selectCompany(companyId).unwrap(); } catch (err) { - this.setState({ error: err.message }); + this.setState({ error: backendErrorFrom(err) }); } finally { this.setState({ busy: false }); } @@ -100,7 +107,7 @@ class CompanySetupDialog extends Component { }) .unwrap(); } catch (err) { - this.setState({ error: err.message }); + this.setState({ error: backendErrorFrom(err) }); } finally { this.setState({ busy: false }); } @@ -110,6 +117,10 @@ class CompanySetupDialog extends Component { void this.props.logout(); } + handleClose(_event, reason) { + if (reason === 'backdropClick' || reason === 'escapeKeyDown') return; + } + render() { const { companies, loading, busy, error, name, iso639, taxrate, reducedtaxrate } = this.state; @@ -123,7 +134,7 @@ class CompanySetupDialog extends Component { parsedReducedtaxrate != null; return ( - + {loading ? 'Unternehmen' @@ -141,9 +152,13 @@ class CompanySetupDialog extends Component { ) : null} {error ? ( - - {error} - + isBackendFailure(error) ? ( + + ) : ( + + {typeof error === 'string' ? error : error.message} + + ) ) : null} {loading ? ( @@ -268,9 +283,9 @@ class CompanySetupDialog extends Component { } const mapDispatchToProps = (dispatch) => ({ - createCompany: (payload) => dispatch(createCompany(payload)), - selectCompany: (companyId) => dispatch(selectCompany(companyId)), - logout: () => dispatch(logout()), + createCompany: (payload) => dispatch(createCompanyThunk(payload)), + selectCompany: (companyId) => dispatch(selectCompanyThunk(companyId)), + logout: () => dispatch(logoutThunk()), }); export default connect(null, mapDispatchToProps)(CompanySetupDialog); diff --git a/src/components/tiles/ActionTile.jsx b/src/components/tiles/ActionTile.jsx new file mode 100644 index 0000000..e2bd20d --- /dev/null +++ b/src/components/tiles/ActionTile.jsx @@ -0,0 +1,18 @@ +import AddIcon from '@mui/icons-material/Add'; +import Typography from '@mui/material/Typography'; +import { Tile } from './Tile'; + +export class ActionTile extends Tile { + renderIcon() { + return ; + } + + renderLabel() { + const { label = 'Aktion' } = this.props; + return ( + + {label} + + ); + } +} diff --git a/src/components/tiles/ArticleTile.jsx b/src/components/tiles/ArticleTile.jsx new file mode 100644 index 0000000..c8a95db --- /dev/null +++ b/src/components/tiles/ArticleTile.jsx @@ -0,0 +1,27 @@ +import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined'; +import Typography from '@mui/material/Typography'; +import { Tile } from './Tile'; +import { formatPrice } from '../../utils/price'; + +export class ArticleTile extends Tile { + renderIcon() { + return ; + } + + renderSecondary() { + const { article } = this.props; + if (!article) return null; + return ( + <> + + {article.sku} + + {article.price != null && ( + + {formatPrice(article.price)} + + )} + + ); + } +} diff --git a/src/components/tiles/BackTile.jsx b/src/components/tiles/BackTile.jsx new file mode 100644 index 0000000..1ed6eb7 --- /dev/null +++ b/src/components/tiles/BackTile.jsx @@ -0,0 +1,18 @@ +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; +import Typography from '@mui/material/Typography'; +import { Tile } from './Tile'; + +export class BackTile extends Tile { + renderIcon() { + return ; + } + + renderLabel() { + const { label = 'Zurück' } = this.props; + return ( + + {label} + + ); + } +} diff --git a/src/components/tiles/CategoryTile.jsx b/src/components/tiles/CategoryTile.jsx new file mode 100644 index 0000000..1910197 --- /dev/null +++ b/src/components/tiles/CategoryTile.jsx @@ -0,0 +1,8 @@ +import FolderIcon from '@mui/icons-material/Folder'; +import { Tile } from './Tile'; + +export class CategoryTile extends Tile { + renderIcon() { + return ; + } +} diff --git a/src/components/tiles/Tile.jsx b/src/components/tiles/Tile.jsx new file mode 100644 index 0000000..60c9dc3 --- /dev/null +++ b/src/components/tiles/Tile.jsx @@ -0,0 +1,81 @@ +import { Component } from 'react'; +import Paper from '@mui/material/Paper'; +import Typography from '@mui/material/Typography'; + +export const TILE_SIZE = 140; + +export const tileBaseSx = { + aspectRatio: '1 / 1', + width: '100%', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + gap: 1, + p: 1.5, + cursor: 'pointer', + userSelect: 'none', + textAlign: 'center', + transition: 'border-color 0.15s, background-color 0.15s', + '&:hover': { + bgcolor: 'action.hover', + borderColor: 'primary.main', + }, +}; + +export class Tile extends Component { + handleClick = () => { + this.props.onClick?.(); + }; + + renderIcon() { + return null; + } + + renderLabel() { + const { label } = this.props; + if (!label) return null; + return ( + + {label} + + ); + } + + renderSecondary() { + const { secondary } = this.props; + if (!secondary) return null; + return ( + + {secondary} + + ); + } + + render() { + const { sx, disabled } = this.props; + return ( + + {this.renderIcon()} + {this.renderLabel()} + {this.renderSecondary()} + + ); + } +} diff --git a/src/components/tiles/index.js b/src/components/tiles/index.js new file mode 100644 index 0000000..fb452d6 --- /dev/null +++ b/src/components/tiles/index.js @@ -0,0 +1,5 @@ +export { TILE_SIZE, Tile, tileBaseSx } from './Tile'; +export { BackTile } from './BackTile'; +export { CategoryTile } from './CategoryTile'; +export { ArticleTile } from './ArticleTile'; +export { ActionTile } from './ActionTile'; diff --git a/src/components/withNavigate.jsx b/src/components/withNavigate.jsx new file mode 100644 index 0000000..4b0354b --- /dev/null +++ b/src/components/withNavigate.jsx @@ -0,0 +1,9 @@ +import { useNavigate } from 'react-router-dom'; + +export default function withNavigate(Wrapped) { + function NavigateWrapper(props) { + const navigate = useNavigate(); + return ; + } + return NavigateWrapper; +} diff --git a/src/main.jsx b/src/main.jsx index ee701dc..e42040e 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -3,14 +3,25 @@ import CssBaseline from '@mui/material/CssBaseline'; import { ThemeProvider } from '@mui/material/styles'; import { Provider } from 'react-redux'; import { SnackbarProvider } from 'notistack'; -import { registerSW } from 'virtual:pwa-register'; import AuthBootstrap from './components/AuthBootstrap'; import App from './App'; import { store } from './store'; import theme from './theme'; import './index.css'; -registerSW({ immediate: true }); +async function clearDevServiceWorker() { + if (!import.meta.env.DEV || !('serviceWorker' in navigator)) return; + const regs = await navigator.serviceWorker.getRegistrations(); + await Promise.all(regs.map((r) => r.unregister())); + if ('caches' in window) { + const keys = await caches.keys(); + await Promise.all(keys.map((k) => caches.delete(k))); + } +} + +if (import.meta.env.DEV) { + void clearDevServiceWorker(); +} createRoot(document.getElementById('root')).render( diff --git a/src/pages/artikel/index.jsx b/src/pages/artikel/index.jsx index e0b0573..ac4f5bb 100644 --- a/src/pages/artikel/index.jsx +++ b/src/pages/artikel/index.jsx @@ -1,5 +1,4 @@ import { Component } from 'react'; -import Alert from '@mui/material/Alert'; import Box from '@mui/material/Box'; import InputAdornment from '@mui/material/InputAdornment'; import List from '@mui/material/List'; @@ -11,6 +10,8 @@ import Typography from '@mui/material/Typography'; import SearchIcon from '@mui/icons-material/Search'; import ClearIcon from '@mui/icons-material/Clear'; import { article } from '../../api'; +import BackendError from '../../components/BackendError'; +import { backendErrorFrom } from '../../utils/backendError'; import { formatPrice } from '../../utils/price'; import { buildSearchUrl, @@ -273,7 +274,7 @@ class ArtikelSearchPage extends Component { categoryFacets: null, totalCount: null, recordset: null, - error: err.message || 'Suche fehlgeschlagen', + error: backendErrorFrom(err), }); } }; @@ -344,9 +345,11 @@ class ArtikelSearchPage extends Component { )} {error && ( - - {error} - + )} {searchActive && ( diff --git a/src/pages/artikel/kategorien.jsx b/src/pages/artikel/kategorien.jsx index 42487e2..63e7d4f 100644 --- a/src/pages/artikel/kategorien.jsx +++ b/src/pages/artikel/kategorien.jsx @@ -1,7 +1,227 @@ +import { Component } from 'react'; +import { connect } from 'react-redux'; +import Box from '@mui/material/Box'; +import CircularProgress from '@mui/material/CircularProgress'; import Typography from '@mui/material/Typography'; +import BackendError from '../../components/BackendError'; +import { + ActionTile, + ArticleTile, + BackTile, + CategoryTile, + TILE_SIZE, +} from '../../components/tiles'; +import withNavigate from '../../components/withNavigate'; +import { selectCompanyId } from '../../store/companySlice'; +import { + categoryDisplayName, + loadCategoryArticles, + loadCategoryTree, + navigateBack, + navigateToCategory, + selectCanNavigateBack, + selectCategoryArticles, + selectCategoryArticlesStatus, + selectCategoryError, + selectCategoryTreeStatus, + selectChildCategories, + selectCurrentCategory, +} from '../../store/categorySlice'; -export default function ArtikelKategorienPage() { - return ( - Kategorien — Inhalt folgt. - ); +const gridSx = { + display: 'grid', + gridTemplateColumns: `repeat(auto-fill, minmax(${TILE_SIZE}px, 1fr))`, + gap: 2, +}; + +class ArtikelKategorienPage extends Component { + componentDidMount() { + this.ensureTreeLoaded(); + this.ensureArticlesLoaded(); + } + + componentDidUpdate(prevProps) { + if (this.props.companyId !== prevProps.companyId) { + this.ensureTreeLoaded(); + } + if (this.props.currentCategoryId !== prevProps.currentCategoryId) { + this.ensureArticlesLoaded(); + } + if ( + this.props.treeStatus === 'succeeded' && + prevProps.treeStatus !== 'succeeded' + ) { + this.ensureArticlesLoaded(); + } + } + + ensureTreeLoaded = () => { + const { companyId, treeStatus } = this.props; + if (!companyId || treeStatus !== 'idle') return; + this.props.loadCategoryTree(companyId); + }; + + ensureArticlesLoaded = () => { + const { currentCategoryId, articlesStatus, rootCategoryId } = this.props; + if (!currentCategoryId || articlesStatus !== 'idle') return; + // Root articles come from tree response; only fetch for subcategories. + if (String(currentCategoryId) === String(rootCategoryId)) return; + this.props.loadCategoryArticles({ categoryId: currentCategoryId }); + }; + + handleRetryTree = () => { + const { companyId } = this.props; + if (companyId) this.props.loadCategoryTree(companyId); + }; + + handleRetryArticles = () => { + const { currentCategoryId } = this.props; + if (currentCategoryId) { + this.props.loadCategoryArticles({ categoryId: currentCategoryId }); + } + }; + + handleCategoryClick = (categoryId) => { + this.props.navigateToCategory(categoryId); + }; + + handleArticleClick = (sku) => { + this.props.navigate(`/artikel/${encodeURIComponent(sku)}`); + }; + + render() { + const { + companyId, + rootCategoryId, + treeStatus, + articlesStatus, + error, + canGoBack, + currentCategory, + currentCategoryId, + childCategories, + articles, + } = this.props; + + if (!companyId) { + return ( + + Kein Unternehmen ausgewählt. + + ); + } + + if (treeStatus === 'loading' || treeStatus === 'idle') { + return ( + + + + ); + } + + if (treeStatus === 'failed') { + return ( + + ); + } + + if (!rootCategoryId) { + return ( + + Kategoriebaum ist leer. + + ); + } + + const categoryName = categoryDisplayName( + currentCategory?.name, + currentCategoryId, + rootCategoryId, + ); + const showArticlesLoading = articlesStatus === 'loading'; + + return ( + <> + {categoryName ? ( + + {categoryName} + + ) : null} + + {error && articlesStatus === 'failed' && ( + + )} + + + {canGoBack && ( + + )} + + {childCategories.map((item) => ( + this.handleCategoryClick(item.id)} + /> + ))} + + {articles.map((item) => ( + this.handleArticleClick(item.sku)} + /> + ))} + + {}} + /> + {}} + /> + + + {showArticlesLoading && ( + + + + )} + + + ); + } } + +const mapStateToProps = (state) => ({ + companyId: selectCompanyId(state), + rootCategoryId: state.category.rootCategoryId, + treeStatus: selectCategoryTreeStatus(state), + articlesStatus: selectCategoryArticlesStatus(state), + error: selectCategoryError(state), + currentCategoryId: state.category.currentCategoryId, + currentCategory: selectCurrentCategory(state), + childCategories: selectChildCategories(state), + articles: selectCategoryArticles(state), + canGoBack: selectCanNavigateBack(state), +}); + +const mapDispatchToProps = { + loadCategoryTree, + loadCategoryArticles, + navigateToCategory, + navigateBack, +}; + +export default withNavigate(connect(mapStateToProps, mapDispatchToProps)(ArtikelKategorienPage)); diff --git a/src/pages/artikel/sku.jsx b/src/pages/artikel/sku.jsx index 3bcd472..8f1ce27 100644 --- a/src/pages/artikel/sku.jsx +++ b/src/pages/artikel/sku.jsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { Link as RouterLink, useNavigate, useParams } from 'react-router-dom'; import Alert from '@mui/material/Alert'; import Box from '@mui/material/Box'; @@ -13,6 +13,8 @@ import TableHead from '@mui/material/TableHead'; import TableRow from '@mui/material/TableRow'; import Typography from '@mui/material/Typography'; import { article } from '../../api'; +import BackendError from '../../components/BackendError'; +import { backendErrorFrom, isBackendFailure } from '../../utils/backendError'; import { formatPrice, getBrutto } from '../../utils/price'; import { formatStock } from './searchFormat'; @@ -272,6 +274,11 @@ export default function ArtikelSkuPage() { const [data, setData] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(true); + const [reloadKey, setReloadKey] = useState(0); + + const retry = useCallback(() => { + setReloadKey((key) => key + 1); + }, []); useEffect(() => { function onKeyDown(e) { @@ -306,7 +313,7 @@ export default function ArtikelSkuPage() { } catch (err) { if (!cancelled) { setData(null); - setError(err.message || 'Artikel konnte nicht geladen werden'); + setError(backendErrorFrom(err)); } } finally { if (!cancelled) setLoading(false); @@ -317,7 +324,7 @@ export default function ArtikelSkuPage() { return () => { cancelled = true; }; - }, [target]); + }, [target, reloadKey]); if (loading) { return ( @@ -328,9 +335,17 @@ export default function ArtikelSkuPage() { } if (error) { + if (isBackendFailure(error)) { + return ( + + ); + } return ( - {error} + {typeof error === 'string' ? error : error.message} ); } diff --git a/src/store/authSlice.js b/src/store/authSlice.js index a7d65fe..1b2d668 100644 --- a/src/store/authSlice.js +++ b/src/store/authSlice.js @@ -31,11 +31,17 @@ export const logout = createAsyncThunk('auth/logout', async () => { window.history.replaceState(null, '', '/'); }); +function mergeUser(state, incoming) { + if (!incoming) return state.user; + const selectedCompanyId = + incoming.selectedCompanyId ?? state.user?.selectedCompanyId ?? null; + return { ...state.user, ...incoming, selectedCompanyId }; +} + const initialState = { user: null, googleClientId: null, status: 'loading', - companyReady: false, }; const authSlice = createSlice({ @@ -46,40 +52,47 @@ const authSlice = createSlice({ builder .addCase(bootstrapSession.fulfilled, (state, action) => { const session = action.payload; - state.user = session.user ?? null; - state.googleClientId = session.googleClientId ?? null; - state.status = session.user ? 'authed' : 'anon'; - state.companyReady = session.user?.selectedCompanyId != null; + state.googleClientId = session.googleClientId ?? state.googleClientId; + if (!session.user) { + if (state.status !== 'authed') { + state.user = null; + state.status = 'anon'; + } + return; + } + state.user = mergeUser(state, session.user); + state.status = 'authed'; }) .addCase(bootstrapSession.rejected, (state) => { - state.user = null; - state.status = 'anon'; - state.companyReady = false; + if (state.status === 'loading') { + state.user = null; + state.status = 'anon'; + } }) .addCase(loginWithGoogle.fulfilled, (state, action) => { const session = action.payload; state.user = session.user; state.googleClientId = session.googleClientId ?? state.googleClientId; state.status = 'authed'; - state.companyReady = session.user?.selectedCompanyId != null; }) .addCase(loginWithGoogle.rejected, (state) => { state.user = null; state.status = 'anon'; - state.companyReady = false; }) .addCase(selectCompany.fulfilled, (state, action) => { - state.user = { ...state.user, ...action.payload }; - state.companyReady = true; + state.user = mergeUser(state, action.payload); + state.status = 'authed'; }) .addCase(createCompany.fulfilled, (state, action) => { - state.user = { ...state.user, ...action.payload }; - state.companyReady = true; + state.user = mergeUser(state, action.payload); + state.status = 'authed'; + }) + .addCase(createCompany.rejected, (state, action) => { + console.error('[redux] createCompany.rejected', action.error?.message, action.payload); }) .addCase(logout.fulfilled, (state) => { state.user = null; state.status = 'anon'; - state.companyReady = false; }); }, }); @@ -88,6 +101,6 @@ export const selectAuthStatus = (state) => state.auth.status; export const selectAuthUser = (state) => state.auth.user; export const selectGoogleClientId = (state) => state.auth.googleClientId; export const selectNeedsCompanySetup = (state) => - state.auth.status === 'authed' && !state.auth.companyReady; + state.auth.status === 'authed' && state.auth.user?.selectedCompanyId == null; export default authSlice.reducer; diff --git a/src/store/categorySlice.js b/src/store/categorySlice.js new file mode 100644 index 0000000..a2fd399 --- /dev/null +++ b/src/store/categorySlice.js @@ -0,0 +1,217 @@ +import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; +import { article, category } from '../api'; +import { catchBackendError, resolveBackendError } from '../utils/backendError'; +import { logout } from './authSlice'; +import { loadCurrentCompany } from './companySlice'; + +export const ROOT_CATEGORY_NAME = 'Home'; + +export function normalizeCategoryNode(item) { + return { + ...item, + id: item.id, + parentId: item.parentId ?? 0, + name: item.name?.trim() || null, + }; +} + +/** Backend: { tree: { tree: [...], articles: [] } } */ +export function parseCategoryTreeResponse(data, companyRootCategoryId = null) { + const payload = data?.tree ?? data; + const rawNodes = payload?.tree ?? payload?.categories ?? data?.categories ?? []; + const list = Array.isArray(rawNodes) ? rawNodes : Object.values(rawNodes); + + const categories = Object.fromEntries( + list.map((item) => [String(item.id), normalizeCategoryNode(item)]), + ); + + const rootCategoryId = data?.rootCategoryId ?? companyRootCategoryId ?? null; + const rootArticles = Array.isArray(payload?.articles) ? payload.articles : []; + + return { rootCategoryId, categories, rootArticles }; +} + +export function categoryDisplayName(name, categoryId, rootCategoryId) { + if (rootCategoryId != null && String(categoryId) === String(rootCategoryId)) { + return ROOT_CATEGORY_NAME; + } + return name?.trim() || ''; +} + +export function normalizeCategoryMap(data) { + return parseCategoryTreeResponse(data).categories; +} + +export function getCategoryChildren(categories, parentId) { + if (!categories) return []; + const parentKey = String(parentId); + return Object.values(categories) + .filter((item) => String(item.parentId ?? 0) === parentKey) + .sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0) || a.id - b.id); +} + +export const loadCategoryTree = createAsyncThunk( + 'category/loadTree', + async (companyId, { rejectWithValue, getState }) => { + try { + const data = await category.getTree({ companyId }); + const fallbackRootId = getState().company.current?.rootCategoryId ?? null; + const { rootCategoryId, categories, rootArticles } = parseCategoryTreeResponse( + data, + fallbackRootId, + ); + return { + companyId, + rootCategoryId, + categories, + rootArticles, + }; + } catch (err) { + return catchBackendError(rejectWithValue)(err); + } + }, +); + +export const loadCategoryArticles = createAsyncThunk( + 'category/loadArticles', + async ({ categoryId, offset = 0, maxCount = 200 } = {}, { rejectWithValue }) => { + try { + const data = await article.byCategory({ categoryId, offset, maxCount }); + return { + categoryId, + recordset: data.recordset ?? [], + length: data.length ?? data.recordset?.length ?? 0, + }; + } catch (err) { + return catchBackendError(rejectWithValue)(err); + } + }, +); + +const initialState = { + companyId: null, + categories: null, + rootCategoryId: null, + rootArticles: [], + currentCategoryId: null, + articles: [], + articlesTotal: 0, + treeStatus: 'idle', + articlesStatus: 'idle', + error: null, +}; + +const categorySlice = createSlice({ + name: 'category', + initialState, + reducers: { + navigateToCategory(state, action) { + state.currentCategoryId = action.payload; + state.articles = []; + state.articlesTotal = 0; + state.articlesStatus = 'idle'; + state.error = null; + }, + navigateBack(state) { + const current = state.categories?.[String(state.currentCategoryId)]; + const parentId = current?.parentId; + if (parentId == null || parentId === 0) return; + if (String(parentId) === String(state.rootCategoryId) || state.categories?.[String(parentId)]) { + state.currentCategoryId = parentId; + state.error = null; + if (String(parentId) === String(state.rootCategoryId)) { + state.articles = state.rootArticles; + state.articlesTotal = state.rootArticles.length; + state.articlesStatus = 'succeeded'; + } else { + state.articles = []; + state.articlesTotal = 0; + state.articlesStatus = 'idle'; + } + } + }, + resetCategoryState() { + return initialState; + }, + }, + extraReducers: (builder) => { + builder + .addCase(loadCategoryTree.pending, (state) => { + state.treeStatus = 'loading'; + state.error = null; + }) + .addCase(loadCategoryTree.fulfilled, (state, action) => { + state.treeStatus = 'succeeded'; + state.companyId = action.payload.companyId; + state.categories = action.payload.categories; + state.rootCategoryId = action.payload.rootCategoryId; + state.currentCategoryId = action.payload.rootCategoryId; + state.rootArticles = action.payload.rootArticles ?? []; + state.articles = state.rootArticles; + state.articlesTotal = state.rootArticles.length; + state.articlesStatus = 'succeeded'; + }) + .addCase(loadCategoryTree.rejected, (state, action) => { + state.treeStatus = 'failed'; + state.error = resolveBackendError(action); + }) + .addCase(loadCategoryArticles.pending, (state) => { + state.articlesStatus = 'loading'; + state.error = null; + }) + .addCase(loadCategoryArticles.fulfilled, (state, action) => { + state.articlesStatus = 'succeeded'; + state.articles = action.payload.recordset; + state.articlesTotal = action.payload.length; + }) + .addCase(loadCategoryArticles.rejected, (state, action) => { + state.articlesStatus = 'failed'; + state.articles = []; + state.articlesTotal = 0; + state.error = resolveBackendError(action); + }) + .addCase(loadCurrentCompany.fulfilled, (state, action) => { + const nextCompanyId = action.payload?.id ?? null; + if (nextCompanyId != null && nextCompanyId !== state.companyId) { + return { + ...initialState, + companyId: nextCompanyId, + }; + } + }) + .addCase(logout.fulfilled, () => initialState); + }, +}); + +export const { + navigateToCategory, + navigateBack, + resetCategoryState, +} = categorySlice.actions; + +export const selectCategoryMap = (state) => state.category.categories; +export const selectCurrentCategoryId = (state) => state.category.currentCategoryId; +export const selectCategoryRootId = (state) => state.category.rootCategoryId; +export const selectCategoryTreeStatus = (state) => state.category.treeStatus; +export const selectCategoryArticles = (state) => state.category.articles; +export const selectCategoryArticlesStatus = (state) => state.category.articlesStatus; +export const selectCategoryError = (state) => state.category.error; + +export const selectCurrentCategory = (state) => { + const id = state.category.currentCategoryId; + if (id == null) return null; + return state.category.categories?.[String(id)] ?? null; +}; + +export const selectChildCategories = (state) => + getCategoryChildren(state.category.categories, state.category.currentCategoryId); + +export const selectCanNavigateBack = (state) => { + const { currentCategoryId, rootCategoryId, categories } = state.category; + if (currentCategoryId == null || rootCategoryId == null) return false; + if (String(currentCategoryId) === String(rootCategoryId)) return false; + const parentId = categories?.[String(currentCategoryId)]?.parentId; + return parentId != null && parentId !== 0; +}; + +export default categorySlice.reducer; diff --git a/src/store/companySlice.js b/src/store/companySlice.js new file mode 100644 index 0000000..a9578d2 --- /dev/null +++ b/src/store/companySlice.js @@ -0,0 +1,103 @@ +import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; +import { company } from '../api'; +import { normalizeCompany } from '../utils/companyForm'; +import { + bootstrapSession, + createCompany, + loginWithGoogle, + logout, + selectCompany, +} from './authSlice'; + +export const loadCurrentCompany = createAsyncThunk( + 'company/loadCurrent', + async (_, { getState }) => { + const companyId = getState().auth.user?.selectedCompanyId; + if (!companyId) return null; + const data = await company.getById(companyId); + return normalizeCompany(data.company); + }, +); + +export const updateCompany = createAsyncThunk( + 'company/update', + async (payload, { getState }) => { + const companyId = getState().auth.user?.selectedCompanyId; + if (!companyId) throw new Error('Kein Unternehmen ausgewählt.'); + await company.update(companyId, payload); + const data = await company.getById(companyId); + return normalizeCompany(data.company, payload); + }, +); + +const initialState = { + current: null, + status: 'idle', + error: null, +}; + +const companySlice = createSlice({ + name: 'company', + initialState, + reducers: {}, + extraReducers: (builder) => { + builder + .addCase(loadCurrentCompany.pending, (state) => { + state.status = 'loading'; + state.error = null; + }) + .addCase(loadCurrentCompany.fulfilled, (state, action) => { + state.status = 'succeeded'; + state.current = action.payload; + }) + .addCase(loadCurrentCompany.rejected, (state, action) => { + state.status = 'failed'; + state.error = action.error.message; + }) + .addCase(updateCompany.pending, (state) => { + state.status = 'loading'; + state.error = null; + }) + .addCase(updateCompany.fulfilled, (state, action) => { + state.status = 'succeeded'; + state.current = action.payload; + }) + .addCase(updateCompany.rejected, (state, action) => { + state.status = 'failed'; + state.error = action.error.message; + }) + .addCase(logout.fulfilled, (state) => { + state.current = null; + state.status = 'idle'; + state.error = null; + }); + }, +}); + +export const selectCurrentCompany = (state) => state.company.current; +export const selectCompanyId = (state) => state.company.current?.id ?? null; +export const selectCompanyStatus = (state) => state.company.status; +export const selectCompanyError = (state) => state.company.error; +export const selectCompanyName = (state) => state.company.current?.name ?? ''; +export const selectCompanyIso639 = (state) => state.company.current?.iso639 ?? 'de'; +export const selectCompanyTaxrate = (state) => state.company.current?.taxrate ?? null; +export const selectCompanyReducedTaxrate = (state) => state.company.current?.reducedtaxrate ?? null; +export const selectCompanyRootCategoryId = (state) => state.company.current?.rootCategoryId ?? null; + +export function companyLoadListener(listenerMiddleware) { + listenerMiddleware.startListening({ + matcher: (action) => + bootstrapSession.fulfilled.match(action) || + loginWithGoogle.fulfilled.match(action) || + selectCompany.fulfilled.match(action) || + createCompany.fulfilled.match(action), + effect: async (_action, api) => { + const companyId = api.getState().auth.user?.selectedCompanyId; + if (companyId) { + api.dispatch(loadCurrentCompany()); + } + }, + }); +} + +export default companySlice.reducer; diff --git a/src/store/index.js b/src/store/index.js index e7b1251..85813d6 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -1,8 +1,45 @@ -import { configureStore } from '@reduxjs/toolkit'; +import { configureStore, createListenerMiddleware } from '@reduxjs/toolkit'; import authReducer from './authSlice'; +import categoryReducer from './categorySlice'; +import companyReducer, { companyLoadListener } from './companySlice'; + +const listenerMiddleware = createListenerMiddleware(); +companyLoadListener(listenerMiddleware); + +function reduxDevLogger() { + return (storeApi) => (next) => (action) => { + if ( + !import.meta.env.DEV || + (!action.type?.startsWith('auth/') && + !action.type?.startsWith('company/') && + !action.type?.startsWith('category/')) + ) { + return next(action); + } + const before = storeApi.getState(); + const result = next(action); + const after = storeApi.getState(); + console.log('[redux]', action.type, { + payload: action.payload, + auth: { + selectedCompanyId: after.auth.user?.selectedCompanyId ?? null, + needsCompanySetup: + after.auth.status === 'authed' && after.auth.user?.selectedCompanyId == null, + }, + company: after.company.current, + }); + return result; + }; +} export const store = configureStore({ reducer: { auth: authReducer, + company: companyReducer, + category: categoryReducer, }, + middleware: (getDefaultMiddleware) => + getDefaultMiddleware() + .concat(listenerMiddleware.middleware) + .concat(import.meta.env.DEV ? reduxDevLogger() : []), }); diff --git a/src/utils/backendError.js b/src/utils/backendError.js new file mode 100644 index 0000000..55acc27 --- /dev/null +++ b/src/utils/backendError.js @@ -0,0 +1,86 @@ +const STATUS_TITLES = { + 400: 'Ungültige Anfrage', + 401: 'Nicht angemeldet', + 403: 'Zugriff verweigert', + 404: 'Nicht gefunden', + 409: 'Konflikt', + 422: 'Ungültige Daten', + 500: 'Serverfehler', + 502: 'Gateway-Fehler', + 503: 'Dienst nicht erreichbar', +}; + +const GENERIC_HTTP_TITLES = new Set([ + 'not found', + 'unauthorized', + 'forbidden', + 'internal server error', + 'bad request', + 'service unavailable', +]); + +/** Normalize any thrown value into a stable backend error object. */ +export function backendErrorFrom(err) { + if (!err) { + return { message: 'Unbekannter Fehler', status: null, path: null, method: null }; + } + if (typeof err === 'string') { + return { message: err, status: null, path: null, method: null }; + } + if (err.message != null && (err.status != null || err.path != null || err.method != null)) { + return { + message: err.message, + status: err.status ?? null, + path: err.path ?? null, + method: err.method ?? null, + }; + } + if (err.message != null) { + return { + message: err.message, + status: err.status ?? null, + path: err.path ?? null, + method: err.method ?? null, + }; + } + return { message: String(err), status: null, path: null, method: null }; +} + +/** Read structured error from a rejected RTK async thunk action. */ +export function resolveBackendError(action) { + if (action.payload) return backendErrorFrom(action.payload); + return backendErrorFrom(action.error); +} + +export function formatBackendError(error) { + const normalized = backendErrorFrom(error); + const { message, status, path, method } = normalized; + + const statusTitle = status != null ? STATUS_TITLES[status] : null; + const genericTitle = + message && GENERIC_HTTP_TITLES.has(message.trim().toLowerCase()) + ? statusTitle + : null; + + const title = statusTitle || genericTitle || (status != null ? `HTTP ${status}` : 'Backend-Fehler'); + + const detail = + message && + message.trim().toLowerCase() !== title?.trim().toLowerCase() && + !GENERIC_HTTP_TITLES.has(message.trim().toLowerCase()) + ? message + : null; + + const request = path ? `${method || 'GET'} ${path}` : null; + + return { title, detail, status, request }; +} + +export function catchBackendError(rejectWithValue) { + return (err) => rejectWithValue(backendErrorFrom(err)); +} + +export function isBackendFailure(error) { + const normalized = backendErrorFrom(error); + return normalized.status != null || normalized.path != null; +} diff --git a/src/utils/companyForm.js b/src/utils/companyForm.js index 2be730f..e6076af 100644 --- a/src/utils/companyForm.js +++ b/src/utils/companyForm.js @@ -16,3 +16,15 @@ export function companyLanguageToIso639(company) { if (company?.languageId === 2) return 'en'; return 'de'; } + +export function normalizeCompany(raw, overrides = {}) { + if (!raw) return null; + return { + id: raw.id, + name: overrides.name ?? raw.name ?? '', + iso639: overrides.iso639 ?? companyLanguageToIso639(raw), + taxrate: overrides.taxrate ?? raw.taxrate ?? null, + reducedtaxrate: overrides.reducedtaxrate ?? raw.reducedtaxrate ?? null, + rootCategoryId: overrides.rootCategoryId ?? raw.rootCategoryId ?? null, + }; +} diff --git a/vite.config.js b/vite.config.js index fb65761..2394f05 100644 --- a/vite.config.js +++ b/vite.config.js @@ -96,7 +96,7 @@ const sharedServer = { }, }; -export default defineConfig({ +export default defineConfig(({ command }) => ({ build: { rolldownOptions: { output: { @@ -117,42 +117,48 @@ export default defineConfig({ }, plugins: [ react(), - VitePWA({ - registerType: 'autoUpdate', - manifest: { - id: '/', - name: 'Dashfront', - short_name: 'Dashfront', - start_url: '/', - scope: '/', - display: 'standalone', - background_color: '#dceeea', - theme_color: '#3b5bdb', - icons: [ - { - src: '/icons/icon-192.png', - sizes: '192x192', - type: 'image/png', - }, - { - src: '/icons/icon-512.png', - sizes: '512x512', - type: 'image/png', - }, - { - src: '/icons/icon-512.png', - sizes: '512x512', - type: 'image/png', - purpose: 'maskable', - }, - ], - }, - workbox: { - globPatterns: ['**/*.{js,css,html,ico,png,svg,webmanifest,woff2}'], - navigateFallback: 'index.html', - }, - }), + ...(command === 'build' + ? [ + VitePWA({ + registerType: 'autoUpdate', + injectRegister: 'auto', + manifest: { + id: '/', + name: 'Dashfront', + short_name: 'Dashfront', + start_url: '/', + scope: '/', + display: 'standalone', + background_color: '#dceeea', + theme_color: '#3b5bdb', + icons: [ + { + src: '/icons/icon-192.png', + sizes: '192x192', + type: 'image/png', + }, + { + src: '/icons/icon-512.png', + sizes: '512x512', + type: 'image/png', + }, + { + src: '/icons/icon-512.png', + sizes: '512x512', + type: 'image/png', + purpose: 'maskable', + }, + ], + }, + workbox: { + globPatterns: ['**/*.{js,css,html,ico,png,svg,webmanifest,woff2}'], + navigateFallback: 'index.html', + navigateFallbackDenylist: [/^\/api/], + }, + }), + ] + : []), ], server: sharedServer, preview: sharedServer, -}); +}));