Route article detail tabs in the URL and pin a preferred default.

Use /artikel/:sku/:tab with a localStorage-backed default, and a pin control to set which tab opens first.
This commit is contained in:
seb
2026-08-01 06:45:25 +02:00
parent 80f093649a
commit 780f946eff
6 changed files with 302 additions and 118 deletions

View File

@@ -81,7 +81,7 @@ class App extends Component {
<Route index element={<ArtikelUebersichtPage />} /> <Route index element={<ArtikelUebersichtPage />} />
<Route path="kategorien/*" element={<RedirectArtikelKategorien />} /> <Route path="kategorien/*" element={<RedirectArtikelKategorien />} />
<Route path="lager" element={<ArtikelLagerPage />} /> <Route path="lager" element={<ArtikelLagerPage />} />
<Route path=":sku" element={<ArtikelSkuPage />} /> <Route path=":sku/:tab?" element={<ArtikelSkuPage />} />
</Route> </Route>
<Route path="auftrag" element={<SectionLayout sectionPath="/auftrag" />}> <Route path="auftrag" element={<SectionLayout sectionPath="/auftrag" />}>
<Route index element={<AuftragAktivePage />} /> <Route index element={<AuftragAktivePage />} />

View File

@@ -1,6 +1,7 @@
import { Component } from 'react'; import { Component } from 'react';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';
import IconButton from '@mui/material/IconButton';
import Tab from '@mui/material/Tab'; import Tab from '@mui/material/Tab';
import Tabs from '@mui/material/Tabs'; import Tabs from '@mui/material/Tabs';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
@@ -11,56 +12,55 @@ import HistoryIcon from '@mui/icons-material/History';
import LabelIcon from '@mui/icons-material/Label'; import LabelIcon from '@mui/icons-material/Label';
import LocalShippingIcon from '@mui/icons-material/LocalShipping'; import LocalShippingIcon from '@mui/icons-material/LocalShipping';
import ManageSearchIcon from '@mui/icons-material/ManageSearch'; import ManageSearchIcon from '@mui/icons-material/ManageSearch';
import PushPinIcon from '@mui/icons-material/PushPin';
import SettingsIcon from '@mui/icons-material/Settings'; import SettingsIcon from '@mui/icons-material/Settings';
import { article } from '../../api'; import { article } from '../../api';
import {
ARTICLE_TAB,
DEFAULT_ARTICLE_TAB,
articleShowsCompoundTab,
readPreferredArticleTab,
writePreferredArticleTab,
} from '../../pages/artikel/articleRoute';
import { notifyError } from '../../utils/notify'; import { notifyError } from '../../utils/notify';
import { PanelTile } from '../panels'; import { PanelTile } from '../panels';
import { PANEL_TILE_BORDER_RADIUS, responsiveColumnSpan } from '../panels/panelTileLayout'; 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 = [ const ALWAYS_TABS = [
{ id: TAB.history, label: 'History', color: '#fed7aa', Icon: HistoryIcon }, { id: ARTICLE_TAB.history, label: 'History', color: '#fed7aa', Icon: HistoryIcon },
{ id: TAB.supplier, label: 'Supplier', color: '#bbf7d0', Icon: LocalShippingIcon }, { id: ARTICLE_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: ARTICLE_TAB.seo,
{ id: TAB.attributes, label: 'Attribute und Merkmale', color: '#bfdbfe', Icon: CategoryIcon }, label: 'SEO & Beschreibung',
color: '#99f6e4',
Icon: ManageSearchIcon,
},
{ id: ARTICLE_TAB.label, label: 'Label', color: '#fde68a', Icon: LabelIcon },
{
id: ARTICLE_TAB.attributes,
label: 'Attribute und Merkmale',
color: '#bfdbfe',
Icon: CategoryIcon,
},
]; ];
const COMPOUND_TAB = { const COMPOUND_TAB = {
id: TAB.compound, id: ARTICLE_TAB.compound,
label: 'Compound Editor', label: 'Compound Editor',
color: '#e9d5ff', color: '#e9d5ff',
Icon: AccountTreeIcon, Icon: AccountTreeIcon,
}; };
const SETTINGS_TAB = { const SETTINGS_TAB = {
id: TAB.settings, id: ARTICLE_TAB.settings,
label: 'Einstellungen', label: 'Einstellungen',
color: '#fca5a5', color: '#fca5a5',
Icon: SettingsIcon, Icon: SettingsIcon,
}; };
function isCompoundArticle(article) {
return (
article?.articleListCount > 0 ||
article?.optionsCount > 0 ||
article?.childCount > 0 ||
article?.fatherArticleId != null
);
}
function visibleTabs(article) { function visibleTabs(article) {
const tabs = [...ALWAYS_TABS]; const tabs = [...ALWAYS_TABS];
if (isCompoundArticle(article)) { if (articleShowsCompoundTab(article)) {
tabs.push(COMPOUND_TAB); tabs.push(COMPOUND_TAB);
} }
tabs.push(SETTINGS_TAB); tabs.push(SETTINGS_TAB);
@@ -86,7 +86,7 @@ function PanelValue({ label, children }) {
function Placeholder({ label }) { function Placeholder({ label }) {
return ( return (
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
{label} — noch nicht verfügbar. {label}
</Typography> </Typography>
); );
} }
@@ -94,21 +94,24 @@ function Placeholder({ label }) {
class ArticleTileTab extends Component { class ArticleTileTab extends Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.state = { tab: TAB.seo, busy: false }; this.state = {
busy: false,
preferredTab: readPreferredArticleTab(),
};
this.handleTabChange = this.handleTabChange.bind(this); this.handleTabChange = this.handleTabChange.bind(this);
this.handlePinDefault = this.handlePinDefault.bind(this);
this.handleDeleteArticle = this.handleDeleteArticle.bind(this); this.handleDeleteArticle = this.handleDeleteArticle.bind(this);
} }
componentDidUpdate(prevProps) { handleTabChange(_event, tab) {
if (prevProps.article === this.props.article) return; this.props.onTabChange?.(tab);
const tabs = visibleTabs(this.props.article);
if (!tabs.some((row) => row.id === this.state.tab)) {
this.setState({ tab: TAB.seo });
}
} }
handleTabChange(_event, tab) { handlePinDefault() {
this.setState({ tab }); const { tab } = this.props;
if (!tab) return;
writePreferredArticleTab(tab);
this.setState({ preferredTab: tab });
} }
async handleDeleteArticle() { async handleDeleteArticle() {
@@ -159,20 +162,20 @@ class ArticleTileTab extends Component {
} }
renderPanel() { renderPanel() {
switch (this.state.tab) { switch (this.props.tab) {
case TAB.history: case ARTICLE_TAB.history:
return <Placeholder label="History" />; return <Placeholder label="History" />;
case TAB.supplier: case ARTICLE_TAB.supplier:
return <Placeholder label="Supplier" />; return <Placeholder label="Supplier" />;
case TAB.seo: case ARTICLE_TAB.seo:
return this.renderSeo(); return this.renderSeo();
case TAB.label: case ARTICLE_TAB.label:
return <Placeholder label="Label" />; return <Placeholder label="Label" />;
case TAB.attributes: case ARTICLE_TAB.attributes:
return <Placeholder label="Attribute und Merkmale" />; return <Placeholder label="Attribute und Merkmale" />;
case TAB.compound: case ARTICLE_TAB.compound:
return <Placeholder label="Compound Editor" />; return <Placeholder label="Compound Editor" />;
case TAB.settings: case ARTICLE_TAB.settings:
return this.renderSettings(); return this.renderSettings();
default: default:
return null; return null;
@@ -180,11 +183,24 @@ class ArticleTileTab extends Component {
} }
render() { render() {
const { article: articleData, wide = 3, minHeight = 200, sx, tileKey } = this.props; const {
article: articleData,
wide = 3,
minHeight = 200,
sx,
tileKey,
tab = DEFAULT_ARTICLE_TAB,
} = this.props;
const { preferredTab } = this.state;
const tabs = visibleTabs(articleData); const tabs = visibleTabs(articleData);
const { tab } = this.state; const active =
const active = tabs.find((row) => row.id === tab) ?? ALWAYS_TABS.find((row) => row.id === TAB.seo); tabs.find((row) => row.id === tab) ??
ALWAYS_TABS.find((row) => row.id === DEFAULT_ARTICLE_TAB);
const activeColor = active?.color; const activeColor = active?.color;
const effectivePreferred = tabs.some((row) => row.id === preferredTab)
? preferredTab
: DEFAULT_ARTICLE_TAB;
const isPinnedDefault = tab === effectivePreferred;
return ( return (
<Box <Box
@@ -197,72 +213,110 @@ class ArticleTileTab extends Component {
'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))', '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 <Box
value={tab}
onChange={this.handleTabChange}
variant="standard"
sx={{ sx={{
display: 'flex',
alignItems: 'flex-end',
gap: 0.5,
minWidth: 0,
position: 'relative', position: 'relative',
zIndex: 1, zIndex: 1,
minHeight: 56,
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: 3,
rowGap: 0,
alignItems: 'stretch',
alignContent: 'flex-end',
},
'& .MuiTabs-indicator': { display: 'none' },
'& .MuiTab-root': {
flexShrink: 0,
maxWidth: 'none',
minHeight: 56,
marginBottom: 0,
},
}} }}
> >
{tabs.map((row) => { <Tabs
const selected = tab === row.id; value={tab}
const { Icon } = row; onChange={this.handleTabChange}
return ( variant="standard"
<Tab sx={{
key={row.id} flex: 1,
value={row.id} minWidth: 0,
aria-label={row.label} minHeight: 56,
title={row.label} px: 0,
icon={<Icon sx={{ fontSize: 40 }} />} mb: 0,
sx={{ borderBottom: 'none',
minHeight: 56, overflow: 'visible',
minWidth: 64, '& .MuiTabs-scroller': {
py: 0.5, px: 0,
px: 1.25, borderBottom: 'none',
border: 'none', overflow: 'visible !important',
borderBottom: 'none', },
borderRadius: '8px 8px 0 0', '& .MuiTabs-list, & .MuiTabs-flexContainer': {
textTransform: 'none', flexWrap: 'wrap-reverse',
bgcolor: row.color, columnGap: 3,
opacity: selected ? 1 : 0.72, rowGap: 0,
color: 'text.primary', alignItems: 'stretch',
zIndex: selected ? 2 : 1, alignContent: 'flex-end',
boxShadow: 'none', },
'&.Mui-selected': { '& .MuiTabs-indicator': { display: 'none' },
color: 'text.primary', '& .MuiTab-root': {
flexShrink: 0,
maxWidth: 'none',
minHeight: 56,
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 sx={{ fontSize: 40 }} />}
sx={{
minHeight: 56,
minWidth: 64,
py: 0.5,
px: 1.25,
border: 'none',
borderBottom: 'none', borderBottom: 'none',
}, borderRadius: '8px 8px 0 0',
}} textTransform: 'none',
/> bgcolor: row.color,
); opacity: selected ? 1 : 0.72,
})} color: 'text.primary',
</Tabs> zIndex: selected ? 2 : 1,
boxShadow: 'none',
'&.Mui-selected': {
color: 'text.primary',
borderBottom: 'none',
},
}}
/>
);
})}
</Tabs>
<IconButton
type="button"
size="small"
aria-label={
isPinnedDefault
? 'Standard-Tab'
: 'Als Standard-Tab festlegen'
}
title={
isPinnedDefault
? 'Standard-Tab'
: 'Als Standard-Tab festlegen'
}
onClick={this.handlePinDefault}
disabled={isPinnedDefault}
sx={{
flexShrink: 0,
mb: 0.75,
color: isPinnedDefault ? 'success.main' : 'action.active',
'&.Mui-disabled': {
color: 'success.main',
opacity: 1,
},
}}
>
<PushPinIcon sx={{ fontSize: 28 }} />
</IconButton>
</Box>
<PanelTile <PanelTile
wide={wide} wide={wide}
minHeight={minHeight} minHeight={minHeight}

View File

@@ -49,6 +49,7 @@ import {
import { notifyError } from '../../utils/notify'; import { notifyError } from '../../utils/notify';
import { PANEL_TILE_KEYS } from '../../theme'; import { PANEL_TILE_KEYS } from '../../theme';
import { formatStock } from '../../pages/artikel/searchFormat'; import { formatStock } from '../../pages/artikel/searchFormat';
import { artikelDetailPath } from '../../pages/artikel/articleRoute';
import { import {
buildCategoryPath, buildCategoryPath,
defaultCategoryPath, defaultCategoryPath,
@@ -551,7 +552,12 @@ function PriceUnitCorner({ hasUnit, onToggleHasUnit, onOpenEditor }) {
); );
} }
export default function ArticleView({ article: articleData, onArticleUpdated }) { export default function ArticleView({
article: articleData,
tab,
onTabChange,
onArticleUpdated,
}) {
const navigate = useNavigate(); const navigate = useNavigate();
const dispatch = useDispatch(); const dispatch = useDispatch();
const [tiersDialogOpen, setTiersDialogOpen] = useState(false); const [tiersDialogOpen, setTiersDialogOpen] = useState(false);
@@ -891,6 +897,8 @@ export default function ArticleView({ article: articleData, onArticleUpdated })
tileKey={PANEL_TILE_KEYS.content} tileKey={PANEL_TILE_KEYS.content}
minHeight={200} minHeight={200}
article={articleData} article={articleData}
tab={tab}
onTabChange={onTabChange}
onArticleDeleted={handleArticleDeleted} onArticleDeleted={handleArticleDeleted}
/> />
@@ -908,7 +916,7 @@ export default function ArticleView({ article: articleData, onArticleUpdated })
{articleData.fatherSku ? ( {articleData.fatherSku ? (
<Link <Link
component={RouterLink} component={RouterLink}
to={`/artikel/${encodeURIComponent(articleData.fatherSku)}`} to={artikelDetailPath(articleData.fatherSku)}
> >
{articleData.fatherSku} {articleData.fatherSku}
</Link> </Link>

View File

@@ -1,11 +1,108 @@
export const ARTICLE_TAB = {
history: 'history',
supplier: 'supplier',
seo: 'seo',
label: 'label',
attributes: 'attributes',
compound: 'compound',
settings: 'settings',
};
/** Factory fallback when nothing is stored in localStorage. */
export const DEFAULT_ARTICLE_TAB = ARTICLE_TAB.seo;
const PREFERRED_ARTICLE_TAB_KEY = 'dashfront:article-default-tab';
const ALL_ARTICLE_TAB_IDS = new Set(Object.values(ARTICLE_TAB));
const BASE_ARTICLE_TAB_IDS = [
ARTICLE_TAB.history,
ARTICLE_TAB.supplier,
ARTICLE_TAB.seo,
ARTICLE_TAB.label,
ARTICLE_TAB.attributes,
];
export function articleSku(article) { export function articleSku(article) {
if (!article) return null; if (!article) return null;
const sku = article.sku?.trim(); const sku = article.sku?.trim();
return sku || null; return sku || null;
} }
export function artikelDetailPath(article) { export function articleShowsCompoundTab(article) {
const sku = articleSku(article); return (
if (!sku) return null; article?.articleListCount > 0 ||
return `/artikel/${encodeURIComponent(sku)}`; article?.optionsCount > 0 ||
article?.childCount > 0 ||
article?.fatherArticleId != null
);
}
export function articleTabIds(article) {
const ids = [...BASE_ARTICLE_TAB_IDS];
if (articleShowsCompoundTab(article)) {
ids.push(ARTICLE_TAB.compound);
}
ids.push(ARTICLE_TAB.settings);
return ids;
}
export function isArticleTabId(tab) {
return ALL_ARTICLE_TAB_IDS.has(tab);
}
export function readPreferredArticleTab() {
try {
const raw = localStorage.getItem(PREFERRED_ARTICLE_TAB_KEY);
if (raw && isArticleTabId(raw)) return raw;
} catch {
// ignore quota / private mode
}
return DEFAULT_ARTICLE_TAB;
}
export function writePreferredArticleTab(tab) {
if (!isArticleTabId(tab)) return;
try {
localStorage.setItem(PREFERRED_ARTICLE_TAB_KEY, tab);
} catch {
// ignore quota / private mode
}
}
/** Preferred default, clamped to tabs visible for this article. */
export function preferredArticleTab(article) {
const preferred = readPreferredArticleTab();
const ids = articleTabIds(article);
if (ids.includes(preferred)) return preferred;
return DEFAULT_ARTICLE_TAB;
}
export function resolveArticleTab(tabParam, article) {
const ids = articleTabIds(article);
if (tabParam && ids.includes(tabParam)) return tabParam;
return preferredArticleTab(article);
}
/**
* @param {object|string} articleOrSku
* @param {string} [tab] omitted → preferred default from localStorage
*/
export function artikelDetailPath(articleOrSku, tab) {
const sku =
typeof articleOrSku === 'string'
? articleOrSku.trim() || null
: articleSku(articleOrSku);
if (!sku) return null;
let segment;
if (isArticleTabId(tab)) {
segment = tab;
} else if (articleOrSku && typeof articleOrSku === 'object') {
segment = preferredArticleTab(articleOrSku);
} else {
segment = readPreferredArticleTab();
}
return `/artikel/${encodeURIComponent(sku)}/${segment}`;
} }

View File

@@ -29,6 +29,7 @@ import {
parseFilter, parseFilter,
} from './searchFormat'; } from './searchFormat';
import { preserveSearchFocus } from './searchFocus'; import { preserveSearchFocus } from './searchFocus';
import { artikelDetailPath } from './articleRoute';
import withSearchParams from './withSearchParams'; import withSearchParams from './withSearchParams';
const resultRowSx = { const resultRowSx = {
@@ -430,7 +431,7 @@ class ArtikelSearchPage extends Component {
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
onClick={() => { onClick={() => {
this.persistQuery(); this.persistQuery();
this.props.navigate(`/artikel/${encodeURIComponent(row.sku)}`); this.props.navigate(artikelDetailPath(row));
}} }}
sx={resultRowSx} sx={resultRowSx}
> >

View File

@@ -8,9 +8,13 @@ import { ArticleView } from '../../components/article';
import BackendError from '../../components/BackendError'; import BackendError from '../../components/BackendError';
import { requireObject } from '../../utils/apiShape'; import { requireObject } from '../../utils/apiShape';
import { backendErrorFrom, isBackendFailure } from '../../utils/backendError'; import { backendErrorFrom, isBackendFailure } from '../../utils/backendError';
import {
artikelDetailPath,
resolveArticleTab,
} from './articleRoute';
export default function ArtikelSkuPage() { export default function ArtikelSkuPage() {
const { sku: skuParam } = useParams(); const { sku: skuParam, tab: tabParam } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const sku = skuParam ? decodeURIComponent(skuParam) : ''; const sku = skuParam ? decodeURIComponent(skuParam) : '';
const [data, setData] = useState(null); const [data, setData] = useState(null);
@@ -69,6 +73,14 @@ export default function ArtikelSkuPage() {
}; };
}, [sku, reloadKey]); }, [sku, reloadKey]);
useEffect(() => {
if (!data) return;
const resolved = resolveArticleTab(tabParam, data);
if (tabParam !== resolved) {
navigate(artikelDetailPath(data, resolved), { replace: true });
}
}, [data, tabParam, navigate]);
if (loading) { if (loading) {
return ( return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}> <Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
@@ -95,5 +107,17 @@ export default function ArtikelSkuPage() {
if (!data) return null; if (!data) return null;
return <ArticleView article={data} onArticleUpdated={setData} />; const tab = resolveArticleTab(tabParam, data);
if (tabParam !== tab) return null;
return (
<ArticleView
article={data}
tab={tab}
onTabChange={(nextTab) => {
navigate(artikelDetailPath(data, nextTab));
}}
onArticleUpdated={setData}
/>
);
} }