This commit is contained in:
seb
2026-07-31 19:38:52 +02:00
parent d08cf7f2df
commit 7cd053ada6
9 changed files with 221 additions and 117 deletions

View File

@@ -5,7 +5,7 @@ import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText';
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import UserMenu from './UserMenu';
import { NAV_ITEMS } from '../nav';
import { NAV_ITEMS, isNavItemActive } from '../nav';
import { artikelQueryPath } from '../pages/artikel/artikelQuery';
const SIDEBAR_WIDTH = 144;
@@ -28,7 +28,7 @@ export default function AppShell() {
>
<List disablePadding sx={{ flexGrow: 1 }}>
{NAV_ITEMS.map(({ path, label, icon: Icon }) => {
const active = pathname === path || pathname.startsWith(`${path}/`);
const active = isNavItemActive(pathname, path);
return (
<ListItemButton

View File

@@ -17,6 +17,7 @@ import ToggleButton from '@mui/material/ToggleButton';
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
import { enqueueSnackbar } from 'notistack';
import BackendError from './BackendError';
import SingleLineTextEdit from './SingleLineTextEdit';
import { selectAuthUser } from '../store/authSlice';
import {
loadCurrentCompany,
@@ -135,14 +136,13 @@ class CompanySettingsDialog extends Component {
</Box>
) : (
<Stack spacing={2}>
<TextField
fullWidth
<SingleLineTextEdit
required
size="small"
label="Name"
value={name}
disabled={busy}
onChange={(e) => this.setState({ name: e.target.value })}
onChange={(nextName) => this.setState({ name: nextName })}
autoComplete="organization"
autoFocus
/>

View File

@@ -5,9 +5,9 @@ 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 TextField from '@mui/material/TextField';
import Alert from '@mui/material/Alert';
import BackendError from './BackendError';
import SingleLineTextEdit from './SingleLineTextEdit';
import { backendErrorFrom, isBackendFailure } from '../utils/backendError';
/**
@@ -79,15 +79,13 @@ class NameDialog extends Component {
)
) : null}
<TextField
fullWidth
<SingleLineTextEdit
required
autoFocus
label="Name"
value={name}
disabled={busy}
onChange={(e) => this.setState({ name: e.target.value })}
autoComplete="off"
onChange={(nextName) => this.setState({ name: nextName })}
/>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>

View File

@@ -1,6 +1,5 @@
import Box from '@mui/material/Box';
import IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip';
import Typography from '@mui/material/Typography';
import { useLocation, useNavigate } from 'react-router-dom';
import { artikelQueryPath } from '../pages/artikel/artikelQuery';
@@ -8,11 +7,10 @@ import { defaultCategoryPath, isKategorienPath } from '../pages/artikel/category
const SUB_NAV_WIDTH = 80;
function isSubNavActive(pathname, basePath, segment, knownSegments) {
function isSubNavActive(pathname, basePath, segment) {
if (!segment) {
if (pathname === basePath || pathname === `${basePath}/`) return true;
const rest = pathname.slice(basePath.length).replace(/^\//, '');
return Boolean(rest) && !rest.includes('/') && !knownSegments.has(rest);
if (isKategorienPath(pathname)) return false;
return pathname === basePath || pathname === `${basePath}/`;
}
if (segment === 'kategorien') {
return isKategorienPath(pathname);
@@ -25,7 +23,6 @@ export default function PageLayout({ title, showTitle = true, subNav, basePath,
const hasSubNav = subNav?.length > 0;
const navigate = useNavigate();
const { pathname } = useLocation();
const knownSegments = new Set((subNav ?? []).map((item) => item.segment).filter(Boolean));
return (
<Box sx={{ display: 'flex', gap: 2, minHeight: 0 }}>
@@ -43,42 +40,41 @@ export default function PageLayout({ title, showTitle = true, subNav, basePath,
>
{hasSubNav &&
subNav.map(({ segment, label, icon: Icon }) => {
const active = isSubNavActive(pathname, basePath, segment, knownSegments);
const active = isSubNavActive(pathname, basePath, segment);
return (
<Tooltip key={segment || 'index'} title={label} placement="right">
<IconButton
type="button"
aria-label={label}
aria-current={active ? 'page' : undefined}
disableRipple
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
if (!segment && basePath === '/artikel') {
navigate(artikelQueryPath());
return;
}
if (segment === 'kategorien') {
navigate(defaultCategoryPath());
return;
}
navigate(segment ? `${basePath}/${segment}` : basePath);
}}
sx={(theme) => ({
width: 80,
height: 80,
borderRadius: '50%',
color: active ? theme.palette.primary.main : theme.palette.text.secondary,
bgcolor: 'transparent',
'&:hover': {
bgcolor: theme.palette.action.hover,
color: theme.palette.primary.main,
},
})}
>
<Icon sx={{ fontSize: 40 }} />
</IconButton>
</Tooltip>
<IconButton
key={segment || 'index'}
type="button"
aria-label={label}
aria-current={active ? 'page' : undefined}
disableRipple
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
if (!segment && basePath === '/artikel') {
navigate(artikelQueryPath());
return;
}
if (segment === 'kategorien') {
navigate(defaultCategoryPath());
return;
}
navigate(segment ? `${basePath}/${segment}` : basePath);
}}
sx={(theme) => ({
width: 80,
height: 80,
borderRadius: '50%',
color: active ? theme.palette.primary.main : theme.palette.text.secondary,
bgcolor: 'transparent',
'&:hover': {
bgcolor: theme.palette.action.hover,
color: theme.palette.primary.main,
},
})}
>
<Icon sx={{ fontSize: 40 }} />
</IconButton>
);
})}
</Box>

View File

@@ -0,0 +1,62 @@
import { Component } from 'react';
import TextField from '@mui/material/TextField';
/**
* Centralized single-line name field — same outlined look everywhere.
* Props: value, onChange(nextValue), plus optional TextField props.
*/
class SingleLineTextEdit extends Component {
render() {
const {
value,
onChange,
inputRef,
label = 'Name',
disabled = false,
readOnly = false,
autoFocus = false,
autoComplete = 'off',
size,
fullWidth = true,
required = false,
sx,
slotProps,
...rest
} = this.props;
return (
<TextField
inputRef={inputRef}
fullWidth={fullWidth}
required={required}
size={size}
variant="outlined"
label={label}
value={value}
disabled={disabled}
onChange={(event) => {
if (!readOnly) onChange(event.target.value);
}}
autoFocus={autoFocus}
autoComplete={autoComplete}
slotProps={{
...slotProps,
input: {
readOnly,
...slotProps?.input,
},
htmlInput: {
spellCheck: false,
readOnly,
tabIndex: readOnly ? -1 : undefined,
...slotProps?.htmlInput,
},
}}
sx={sx}
{...rest}
/>
);
}
}
export default SingleLineTextEdit;

View File

@@ -1,8 +1,8 @@
import { Link as RouterLink } from 'react-router-dom';
import Box from '@mui/material/Box';
import Link from '@mui/material/Link';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import SingleLineTextEdit from '../SingleLineTextEdit';
import { article } from '../../api';
import { EditablePanelTile, PanelTile, PanelTileGrid } from '../panels';
import { requireObject } from '../../utils/apiShape';
@@ -82,6 +82,35 @@ function nameDraftValid(draft) {
return draft.name.trim().length > 0;
}
const articleNameTextSx = {
fontWeight: 700,
lineHeight: 1.2,
};
const articleNameFieldSx = {
'& .MuiInputBase-input': {
fontSize: '2.125rem',
...articleNameTextSx,
},
};
const articleNameFieldHiddenSx = {
...articleNameFieldSx,
pointerEvents: 'none',
'& .MuiOutlinedInput-notchedOutline': {
borderColor: 'transparent',
},
'& .MuiOutlinedInput-root:hover .MuiOutlinedInput-notchedOutline': {
borderColor: 'transparent',
},
'& .MuiOutlinedInput-root.Mui-focused .MuiOutlinedInput-notchedOutline': {
borderColor: 'transparent',
},
'& .MuiInputLabel-root': {
visibility: 'hidden',
},
};
export default function ArticleView({ article: articleData, onArticleUpdated }) {
const basePrice = primaryPrice(articleData);
const brutto = priceBrutto(basePrice);
@@ -107,42 +136,22 @@ export default function ArticleView({ article: articleData, onArticleUpdated })
isDirty={nameDraftDirty}
canSave={nameDraftValid}
renderDisplay={({ name }) => (
<Box
sx={{
display: 'flex',
alignItems: 'center',
minHeight: { xs: 0, sm: 200 },
pr: 5,
}}
>
<Typography variant="h6" sx={{ fontWeight: 700, lineHeight: 1.25 }}>
{name}
</Typography>
</Box>
<SingleLineTextEdit
label="Name"
value={name}
readOnly
onChange={() => {}}
sx={articleNameFieldHiddenSx}
/>
)}
renderEdit={(draft, onChange, inputRef) => (
<Box
sx={{
display: 'flex',
alignItems: 'center',
minHeight: { xs: 0, sm: 200 },
}}
>
<TextField
inputRef={inputRef}
fullWidth
variant="standard"
value={draft.name}
onChange={(event) => onChange({ name: event.target.value })}
sx={{
'& .MuiInput-input': {
fontSize: '1.25rem',
fontWeight: 700,
lineHeight: 1.25,
},
}}
/>
</Box>
<SingleLineTextEdit
inputRef={inputRef}
label="Name"
value={draft.name}
onChange={(name) => onChange({ name })}
sx={articleNameFieldSx}
/>
)}
/>

View File

@@ -17,6 +17,8 @@ function valuesEqual(a, b) {
return JSON.stringify(a) === JSON.stringify(b);
}
const ACTION_PADDING = 12;
/**
* Panel tile with centralized inline-edit chrome.
* Pass `onSave` to enable edit UI; omit it for read-only tiles (no edit icon).
@@ -120,14 +122,13 @@ class EditablePanelTileBase extends Component {
};
renderTitle() {
const { title } = this.props;
const { editing } = this.state;
const { title, onSave } = this.props;
if (!title) return null;
return (
<Typography
variant="overline"
color="text.secondary"
sx={{ display: 'block', mb: 1, pr: editing ? 9 : 5 }}
sx={{ display: 'block', mb: 1, pr: onSave ? 9 : 0 }}
>
{title}
</Typography>
@@ -148,32 +149,39 @@ class EditablePanelTileBase extends Component {
if (!editing) {
return (
<IconButton
onClick={this.handleStartEdit}
aria-label="Bearbeiten"
sx={{ ...actionSx, right: 4 }}
>
<EditOutlinedIcon sx={iconSx} />
</IconButton>
<>
<IconButton
disabled
aria-hidden
tabIndex={-1}
sx={{
...actionSx,
right: 48,
visibility: 'hidden',
pointerEvents: 'none',
}}
>
<SaveOutlinedIcon sx={iconSx} />
</IconButton>
<IconButton
onClick={this.handleStartEdit}
aria-label="Bearbeiten"
sx={{ ...actionSx, right: 4 }}
>
<EditOutlinedIcon sx={iconSx} />
</IconButton>
</>
);
}
return (
<>
<IconButton
onClick={this.handleCancel}
disabled={saving}
aria-label="Abbrechen"
sx={{ ...actionSx, right: 48 }}
>
<CloseOutlinedIcon sx={iconSx} />
</IconButton>
<IconButton
onClick={this.handleSave}
disabled={!this.canSave()}
aria-label="Speichern"
color={this.canSave() ? 'primary' : 'default'}
sx={{ ...actionSx, right: 4 }}
sx={{ ...actionSx, right: 48 }}
>
{saving ? (
<CircularProgress size={22} color="inherit" />
@@ -181,23 +189,25 @@ class EditablePanelTileBase extends Component {
<SaveOutlinedIcon sx={iconSx} />
)}
</IconButton>
<IconButton
onClick={this.handleCancel}
disabled={saving}
aria-label="Abbrechen"
sx={{ ...actionSx, right: 4 }}
>
<CloseOutlinedIcon sx={iconSx} />
</IconButton>
</>
);
}
render() {
const {
theme,
wide = 1,
sx,
minHeight = 120,
tileKey,
renderDisplay,
renderEdit,
} = this.props;
const { onSave, theme, wide = 1, sx, minHeight = 120, tileKey, renderDisplay, renderEdit } =
this.props;
const { editing, draft, error } = this.state;
const savedValue = this.getSavedValue();
const pending = this.isDirty();
const actionPadding = onSave ? ACTION_PADDING : 0;
return (
<Paper
@@ -206,6 +216,7 @@ class EditablePanelTileBase extends Component {
wide,
sx,
minHeight,
editing,
pending,
tileKey,
theme,
@@ -214,7 +225,13 @@ class EditablePanelTileBase extends Component {
{this.renderActions()}
{this.renderTitle()}
<Box
sx={{ flex: 1, minWidth: 0, pr: editing ? 1 : 0 }}
sx={{
flex: 1,
minWidth: 0,
pr: actionPadding,
display: 'flex',
alignItems: 'center',
}}
onKeyDown={editing ? this.handleEditKeyDown : undefined}
>
{editing && draft != null

View File

@@ -1,6 +1,7 @@
const GRID_COLUMNS = 4;
export const PENDING_EDIT_BORDER = '#ec4899';
export const PENDING_EDIT_BORDER = '#e11d48';
export const EDITING_TILE_BORDER = '#94a3b8';
function clampWide(wide) {
return Math.min(Math.max(wide, 1), 3);
@@ -24,10 +25,16 @@ export function panelTileBackground(theme, tileKey) {
export const PANEL_TILE_BORDER_RADIUS = 4;
function tileRingBoxShadow(ringColor, elevationShadow) {
const inset = `inset 0 0 0 ${PENDING_EDIT_RING_PX}px ${ringColor}`;
return elevationShadow ? `${inset}, ${elevationShadow}` : inset;
}
export function panelTilePaperSx({
wide,
sx,
minHeight = 120,
editing = false,
pending = false,
tileKey,
theme,
@@ -35,6 +42,11 @@ export function panelTilePaperSx({
const tileBg = panelTileBackground(theme, tileKey);
const elevationShadow = theme?.shadows?.[4];
const ringColor = pending
? PENDING_EDIT_BORDER
: editing
? EDITING_TILE_BORDER
: null;
return {
gridColumn: responsiveColumnSpan(wide),
@@ -46,9 +58,9 @@ export function panelTilePaperSx({
border: 'none',
borderRadius: PANEL_TILE_BORDER_RADIUS,
...(tileBg ? { bgcolor: tileBg } : {}),
...(pending && elevationShadow
...(ringColor
? {
boxShadow: `inset 0 0 0 ${PENDING_EDIT_RING_PX}px ${PENDING_EDIT_BORDER}, ${elevationShadow}`,
boxShadow: tileRingBoxShadow(ringColor, elevationShadow),
}
: {}),
...sx,

View File

@@ -10,6 +10,7 @@ import ListAltIcon from '@mui/icons-material/ListAlt';
import ArchiveIcon from '@mui/icons-material/Archive';
import GroupsIcon from '@mui/icons-material/Groups';
import ContactPageIcon from '@mui/icons-material/ContactPage';
import { isKategorienPath } from './pages/artikel/categoryRoute';
export const NAV_ITEMS = [
{ path: '/artikel', label: 'Artikel', icon: Inventory2Icon },
@@ -19,6 +20,15 @@ export const NAV_ITEMS = [
{ path: '/infos', label: 'Infos', icon: InfoIcon },
];
export function isNavItemActive(pathname, navPath) {
if (navPath === '/artikel') {
return pathname === navPath
|| pathname.startsWith(`${navPath}/`)
|| isKategorienPath(pathname);
}
return pathname === navPath || pathname.startsWith(`${navPath}/`);
}
export const SUB_NAV = {
'/artikel': [
{ segment: '', icon: ViewListIcon },