Outsource custom GUI class components to dashgui library
Move reusable GUI primitives (PanelTile, EditablePanelTile, base Tile, ElevatedPill, SingleLineTextEdit) into the shared dashgui package and consume them via a file: dependency. EditablePanelTile now accepts an onError prop so error notification stays app-side.
This commit is contained in:
@@ -24,7 +24,7 @@ import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
|
||||
import PointOfSaleIcon from '@mui/icons-material/PointOfSale';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
import BackendError from './BackendError';
|
||||
import SingleLineTextEdit from './SingleLineTextEdit';
|
||||
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
|
||||
import { StorageManagerContext } from '../api/storageManagerContext';
|
||||
import { requireDefined, requireObject } from '../utils/apiShape';
|
||||
import { ISO639_OPTIONS, parseTaxRate } from '../utils/companyForm';
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import { Component } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Switch from '@mui/material/Switch';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { alpha } from '@mui/material/styles';
|
||||
|
||||
/** Base pill with elevation, shared styling, and an overridable trailing element. */
|
||||
class ElevatedPill extends Component {
|
||||
renderTrailing() {
|
||||
return null;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { label, active = false, onClick } = this.props;
|
||||
return (
|
||||
<Box
|
||||
component="label"
|
||||
onClick={onClick}
|
||||
sx={(theme) => ({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
px: 1.5,
|
||||
py: 0.25,
|
||||
borderRadius: 999,
|
||||
background: active
|
||||
? `linear-gradient(180deg, ${alpha(theme.palette.primary.main, 0.28)}, ${alpha(
|
||||
theme.palette.primary.main,
|
||||
0.22,
|
||||
)})`
|
||||
: `linear-gradient(180deg, ${alpha(theme.palette.primary.main, 0.16)}, ${alpha(
|
||||
theme.palette.primary.main,
|
||||
0.10,
|
||||
)})`,
|
||||
color: 'text.primary',
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
boxShadow: theme.shadows[1],
|
||||
transition: 'background 150ms ease, box-shadow 150ms ease',
|
||||
})}
|
||||
>
|
||||
<Typography variant="body2" sx={{ lineHeight: 1 }}>
|
||||
{label}
|
||||
</Typography>
|
||||
{this.renderTrailing()}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class ToggleElevatedPill extends ElevatedPill {
|
||||
renderTrailing() {
|
||||
const { checked, onToggle } = this.props;
|
||||
return (
|
||||
<Switch
|
||||
size="small"
|
||||
checked={checked}
|
||||
onChange={(event) => onToggle(event.target.checked)}
|
||||
sx={(theme) => ({
|
||||
'& .MuiSwitch-switchBase': {
|
||||
color: checked ? 'success.main' : 'grey.300',
|
||||
},
|
||||
'& .Mui-checked': {
|
||||
color: 'success.main',
|
||||
},
|
||||
'& .Mui-checked + .MuiSwitch-track': {
|
||||
backgroundColor: alpha(theme.palette.success.main, 0.35),
|
||||
},
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class ActionElevatedPill extends ElevatedPill {
|
||||
renderTrailing() {
|
||||
return this.props.icon;
|
||||
}
|
||||
}
|
||||
|
||||
export default ElevatedPill;
|
||||
@@ -7,7 +7,7 @@ import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import BackendError from './BackendError';
|
||||
import SingleLineTextEdit from './SingleLineTextEdit';
|
||||
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
|
||||
import { backendErrorFrom, isBackendFailure } from '../utils/backendError';
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,7 +13,7 @@ import PageLayout from './PageLayout';
|
||||
import { NAV_ITEMS, SUB_NAV, resolveActiveSub } from '../nav';
|
||||
import { isKategorienPath } from '../pages/artikel/categoryRoute';
|
||||
import { selectRenameMode, setRenameMode } from '../store/categorySlice';
|
||||
import { ActionElevatedPill, ToggleElevatedPill } from './ElevatedPill';
|
||||
import { ActionElevatedPill, ToggleElevatedPill } from 'dashgui/ElevatedPill';
|
||||
import { article, category } from '../api';
|
||||
|
||||
function triggerDownload(blob, filename) {
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
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;
|
||||
@@ -2,7 +2,7 @@ import { PureComponent, createRef } from 'react';
|
||||
import ContentPasteIcon from '@mui/icons-material/ContentPaste';
|
||||
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
|
||||
import { StorageManagerContext } from '../../api/storageManagerContext';
|
||||
import { PanelTile } from '../panels';
|
||||
import { PanelTile } from 'dashgui/panels';
|
||||
import { requireObject } from '../../utils/apiShape';
|
||||
import { notifyError } from '../../utils/notify';
|
||||
import { prepareArticlePhoto } from '../../utils/prepareArticlePhoto';
|
||||
|
||||
@@ -24,8 +24,8 @@ import {
|
||||
} from '../../pages/artikel/articleRoute';
|
||||
import { router } from '../../appRouter';
|
||||
import { matchPath } from 'react-router-dom';
|
||||
import { PanelTile } from '../panels';
|
||||
import { PANEL_TILE_BORDER_RADIUS, responsiveColumnSpan } from '../panels/panelTileLayout';
|
||||
import { PanelTile } from 'dashgui/panels';
|
||||
import { PANEL_TILE_BORDER_RADIUS, responsiveColumnSpan } from 'dashgui/panels/panelTileLayout';
|
||||
import ArticleAttributesTab from './tabs/ArticleAttributesTab';
|
||||
import ArticleCompoundTab from './tabs/ArticleCompoundTab';
|
||||
import ArticleHistoryTab from './tabs/ArticleHistoryTab';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { PureComponent } from 'react';
|
||||
import { PanelTileGrid } from '../panels';
|
||||
import { PanelTileGrid } from 'dashgui/panels';
|
||||
import { StorageManagerContext } from '../../api/storageManagerContext';
|
||||
import withNavigate from '../withNavigate';
|
||||
import {
|
||||
|
||||
@@ -3,7 +3,7 @@ import Autocomplete from '@mui/material/Autocomplete';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Box from '@mui/material/Box';
|
||||
import { EditablePanelTile } from '../../panels';
|
||||
import { EditablePanelTile } from 'dashgui/panels';
|
||||
import { StorageManagerContext } from '../../../api/storageManagerContext';
|
||||
import { requireDefined, requireObject } from '../../../utils/apiShape';
|
||||
import { notifyError } from '../../../utils/notify';
|
||||
@@ -150,6 +150,7 @@ export default class ArticleViewBrand extends PureComponent {
|
||||
color="#e9d5ff"
|
||||
value={value}
|
||||
onSave={this.handleSave}
|
||||
onError={notifyError}
|
||||
isDirty={brandDraftDirty}
|
||||
canSave={brandDraftValid}
|
||||
display={<PanelValue>{value.name || null}</PanelValue>}
|
||||
|
||||
@@ -3,10 +3,11 @@ import Autocomplete from '@mui/material/Autocomplete';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Box from '@mui/material/Box';
|
||||
import { EditablePanelTile } from '../../panels';
|
||||
import { EditablePanelTile } from 'dashgui/panels';
|
||||
import { StorageManagerContext } from '../../../api/storageManagerContext';
|
||||
import { requireObject } from '../../../utils/apiShape';
|
||||
import { articleEans } from '../../../utils/articleDetail';
|
||||
import { notifyError } from '../../../utils/notify';
|
||||
class PanelValue extends Component {
|
||||
render() {
|
||||
const { label, children } = this.props;
|
||||
@@ -115,6 +116,7 @@ export default class ArticleViewEANs extends PureComponent {
|
||||
contentAlignItems="flex-start"
|
||||
value={value}
|
||||
onSave={this.handleSave}
|
||||
onError={notifyError}
|
||||
isDirty={eansDraftDirty}
|
||||
canSave={eansDraftValid}
|
||||
display={<EansDisplay sku={articleData.sku} eans={value.eans} />}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Component, PureComponent } from 'react';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import Link from '@mui/material/Link';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { PanelTile } from '../../panels';
|
||||
import { PanelTile } from 'dashgui/panels';
|
||||
import { artikelDetailPath } from '../../../pages/artikel/articleRoute';
|
||||
|
||||
class CountLine extends Component {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Component, PureComponent } from 'react';
|
||||
import { EditablePanelTile } from '../../panels';
|
||||
import SingleLineTextEdit from '../../SingleLineTextEdit';
|
||||
import { EditablePanelTile } from 'dashgui/panels';
|
||||
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
|
||||
import { StorageManagerContext } from '../../../api/storageManagerContext';
|
||||
import { getCategoryBreadcrumbs } from '../../../store/categorySlice';
|
||||
import { Box, Chip, IconButton } from '@mui/material';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import CategoryPickerDialog from '../CategoryPickerDialog';
|
||||
import { notifyError } from '../../../utils/notify';
|
||||
|
||||
const articleNameTextSx = {
|
||||
fontWeight: 700,
|
||||
@@ -288,6 +289,7 @@ export default class ArticleViewName extends PureComponent {
|
||||
color="#ffffff"
|
||||
value={value}
|
||||
onSave={this.handleSave}
|
||||
onError={notifyError}
|
||||
isDirty={() => draft != null && draftDirty(draft, value)}
|
||||
canSave={() => draft != null && draftValid(draft)}
|
||||
display={
|
||||
|
||||
@@ -3,7 +3,7 @@ import Box from '@mui/material/Box';
|
||||
import InputAdornment from '@mui/material/InputAdornment';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { EditablePanelTile } from '../../panels';
|
||||
import { EditablePanelTile } from 'dashgui/panels';
|
||||
import ArticlePriceTiersDialog from '../ArticlePriceTiersDialog';
|
||||
import ArticleUnitEditorDialog from '../ArticleUnitEditorDialog';
|
||||
import { StorageManagerContext } from '../../../api/storageManagerContext';
|
||||
@@ -510,6 +510,7 @@ export default class ArticleViewPrice extends PureComponent {
|
||||
contentAlignItems="flex-start"
|
||||
value={priceValue}
|
||||
onSave={this.handleSave}
|
||||
onError={notifyError}
|
||||
isDirty={priceDraftDirty}
|
||||
canSave={priceDraftValid}
|
||||
overlay={({ editing, draft, onChange }) => (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Component, PureComponent } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { PanelTile } from '../../panels';
|
||||
import { PanelTile } from 'dashgui/panels';
|
||||
import { formatStock } from '../../../pages/artikel/searchFormat';
|
||||
|
||||
class PanelValue extends Component {
|
||||
|
||||
@@ -4,7 +4,7 @@ import CircularProgress from '@mui/material/CircularProgress';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
import BackendError from '../BackendError';
|
||||
import { PanelTile, PanelTileGrid } from '../panels';
|
||||
import { PanelTile, PanelTileGrid } from 'dashgui/panels';
|
||||
import { StorageManagerContext } from '../../api/storageManagerContext';
|
||||
import { requireArray, requireDefined, requireObject } from '../../utils/apiShape';
|
||||
import { backendErrorFrom } from '../../utils/backendError';
|
||||
|
||||
@@ -2,8 +2,8 @@ import { PureComponent } from 'react';
|
||||
import Autocomplete from '@mui/material/Autocomplete';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import SingleLineTextEdit from '../SingleLineTextEdit';
|
||||
import { PanelTile } from '../panels';
|
||||
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
|
||||
import { PanelTile } from 'dashgui/panels';
|
||||
import { manifestConfig, partyDisplayName } from './manifestConfig';
|
||||
|
||||
class ManifestHeader extends PureComponent {
|
||||
|
||||
@@ -9,7 +9,7 @@ import TableContainer from '@mui/material/TableContainer';
|
||||
import TableHead from '@mui/material/TableHead';
|
||||
import TableRow from '@mui/material/TableRow';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import { PanelTile } from '../panels';
|
||||
import { PanelTile } from 'dashgui/panels';
|
||||
import { LINE_COLUMNS } from './manifestConfig';
|
||||
import ManifestFormLine from './ManifestFormLine';
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
|
||||
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
import BackendError from '../BackendError';
|
||||
import { PanelTile, PanelTileGrid } from '../panels';
|
||||
import { PanelTile, PanelTileGrid } from 'dashgui/panels';
|
||||
import { StorageManagerContext } from '../../api/storageManagerContext';
|
||||
import { backendErrorFrom } from '../../utils/backendError';
|
||||
import { notifyError } from '../../utils/notify';
|
||||
|
||||
@@ -1,278 +0,0 @@
|
||||
import { Component, createRef } from 'react';
|
||||
import CloseOutlinedIcon from '@mui/icons-material/CloseOutlined';
|
||||
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
|
||||
import SaveOutlinedIcon from '@mui/icons-material/SaveOutlined';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import Box from '@mui/material/Box';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { injectMuiTheme } from './injectMuiTheme';
|
||||
import { notifyError } from '../../utils/notify';
|
||||
import { panelTilePaperSx, TILE_ACTION_PADDING, tileActionIconSx, tileActionSx } from './panelTileLayout';
|
||||
|
||||
function valuesEqual(a, b) {
|
||||
return JSON.stringify(a) === JSON.stringify(b);
|
||||
}
|
||||
|
||||
/**
|
||||
* Panel tile with centralized inline-edit chrome.
|
||||
* Pass `onSave` to enable edit UI; omit it for read-only tiles (no edit icon).
|
||||
*
|
||||
* Content:
|
||||
* display — React node shown when not editing
|
||||
* edit — React node, or ( { draft, onChange, inputRef } ) => node, while editing
|
||||
* overlay — optional ( { editing, draft, onChange } ) => node
|
||||
*/
|
||||
class EditablePanelTileBase extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.inputRef = createRef();
|
||||
this.state = {
|
||||
editing: false,
|
||||
draft: null,
|
||||
saving: false,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate(_prevProps, prevState) {
|
||||
if (!prevState.editing && this.state.editing) {
|
||||
this.inputRef.current?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
getSavedValue() {
|
||||
return this.props.value;
|
||||
}
|
||||
|
||||
isDirty() {
|
||||
const { draft, editing } = this.state;
|
||||
if (!editing || draft == null) return false;
|
||||
if (this.props.isDirty) {
|
||||
return this.props.isDirty(draft, this.getSavedValue());
|
||||
}
|
||||
return !valuesEqual(draft, this.getSavedValue());
|
||||
}
|
||||
|
||||
canSave() {
|
||||
const { draft, saving } = this.state;
|
||||
if (saving || !this.isDirty() || draft == null) return false;
|
||||
if (this.props.canSave) {
|
||||
return this.props.canSave(draft, this.getSavedValue());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
handleStartEdit = (event) => {
|
||||
event.stopPropagation();
|
||||
this.setState({
|
||||
editing: true,
|
||||
draft: structuredClone(this.getSavedValue()),
|
||||
error: null,
|
||||
});
|
||||
};
|
||||
|
||||
handleCancel = (event) => {
|
||||
event?.stopPropagation();
|
||||
this.setState({
|
||||
editing: false,
|
||||
draft: null,
|
||||
error: null,
|
||||
});
|
||||
};
|
||||
|
||||
handleDraftChange = (nextDraft) => {
|
||||
this.setState({ draft: nextDraft, error: null });
|
||||
};
|
||||
|
||||
handleEditKeyDown = (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.handleCancel();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (this.canSave()) {
|
||||
void this.handleSave(event);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
handleSave = async (event) => {
|
||||
event?.stopPropagation();
|
||||
if (!this.canSave()) return;
|
||||
|
||||
const { onSave } = this.props;
|
||||
const { draft } = this.state;
|
||||
|
||||
try {
|
||||
await onSave(draft);
|
||||
this.setState({ editing: false, draft: null, saving: false, error: null });
|
||||
} catch (err) {
|
||||
const normalized = notifyError(err);
|
||||
this.setState({ error: normalized.message });
|
||||
}
|
||||
};
|
||||
|
||||
renderTitle() {
|
||||
const { title, onSave } = this.props;
|
||||
if (!title) return null;
|
||||
return (
|
||||
<Typography
|
||||
variant="overline"
|
||||
color="text.secondary"
|
||||
sx={{ display: 'block', mb: 1, pr: onSave ? 9 : 0 }}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
renderActions() {
|
||||
const { onSave } = this.props;
|
||||
if (!onSave) return null;
|
||||
|
||||
const { editing, saving } = this.state;
|
||||
const iconSx = tileActionIconSx;
|
||||
|
||||
if (!editing) {
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
disabled
|
||||
aria-hidden
|
||||
tabIndex={-1}
|
||||
sx={{
|
||||
...tileActionSx,
|
||||
right: 48,
|
||||
visibility: 'hidden',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
<SaveOutlinedIcon sx={iconSx} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={this.handleStartEdit}
|
||||
aria-label="Bearbeiten"
|
||||
sx={{ ...tileActionSx, right: 4 }}
|
||||
>
|
||||
<EditOutlinedIcon sx={iconSx} />
|
||||
</IconButton>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
onClick={this.handleSave}
|
||||
disabled={!this.canSave()}
|
||||
aria-label="Speichern"
|
||||
color={this.canSave() ? 'primary' : 'default'}
|
||||
sx={{ ...tileActionSx, right: 48 }}
|
||||
>
|
||||
{saving ? (
|
||||
<CircularProgress size={22} color="inherit" />
|
||||
) : (
|
||||
<SaveOutlinedIcon sx={iconSx} />
|
||||
)}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={this.handleCancel}
|
||||
disabled={saving}
|
||||
aria-label="Abbrechen"
|
||||
sx={{ ...tileActionSx, right: 4 }}
|
||||
>
|
||||
<CloseOutlinedIcon sx={iconSx} />
|
||||
</IconButton>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
renderBody() {
|
||||
const { display, edit } = this.props;
|
||||
const { editing, draft } = this.state;
|
||||
|
||||
if (editing && draft != null) {
|
||||
if (typeof edit === 'function') {
|
||||
return edit({
|
||||
draft,
|
||||
onChange: this.handleDraftChange,
|
||||
inputRef: this.inputRef,
|
||||
});
|
||||
}
|
||||
return edit;
|
||||
}
|
||||
return display;
|
||||
}
|
||||
|
||||
renderOverlayNode() {
|
||||
const { overlay } = this.props;
|
||||
const { editing, draft } = this.state;
|
||||
if (typeof overlay !== 'function') return null;
|
||||
return overlay({
|
||||
editing,
|
||||
draft,
|
||||
onChange: this.handleDraftChange,
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
onSave,
|
||||
theme,
|
||||
wide = 1,
|
||||
sx,
|
||||
minHeight = 120,
|
||||
color,
|
||||
contentAlignItems = 'center',
|
||||
} = this.props;
|
||||
const { editing, error } = this.state;
|
||||
const pending = this.isDirty();
|
||||
const actionPadding = onSave ? TILE_ACTION_PADDING : 0;
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={4}
|
||||
sx={panelTilePaperSx({
|
||||
wide,
|
||||
sx,
|
||||
minHeight,
|
||||
editing,
|
||||
pending,
|
||||
color,
|
||||
theme,
|
||||
})}
|
||||
>
|
||||
{this.renderActions()}
|
||||
{this.renderOverlayNode()}
|
||||
{this.renderTitle()}
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
pr: actionPadding,
|
||||
display: 'flex',
|
||||
alignItems: contentAlignItems,
|
||||
}}
|
||||
onKeyDown={editing ? this.handleEditKeyDown : undefined}
|
||||
>
|
||||
{this.renderBody()}
|
||||
</Box>
|
||||
{error ? (
|
||||
<Alert severity="error" sx={{ mt: 1, py: 0 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
) : null}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const EditablePanelTile = injectMuiTheme(EditablePanelTileBase);
|
||||
@@ -1,123 +0,0 @@
|
||||
import { Component, createRef } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import SaveOutlinedIcon from '@mui/icons-material/SaveOutlined';
|
||||
import { injectMuiTheme } from './injectMuiTheme';
|
||||
import {
|
||||
GRID_COLUMNS,
|
||||
TILE_ACTION_PADDING,
|
||||
panelTilePaperSx,
|
||||
tileActionIconSx,
|
||||
tileActionSx,
|
||||
} from './panelTileLayout';
|
||||
|
||||
class PanelTileBase extends Component {
|
||||
renderTitle() {
|
||||
const { title, action } = this.props;
|
||||
if (!title) return null;
|
||||
return (
|
||||
<Typography
|
||||
variant="overline"
|
||||
color="text.secondary"
|
||||
sx={{ display: 'block', mb: 1, pr: action ? 9 : 0 }}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
renderAction() {
|
||||
const { action } = this.props;
|
||||
if (!action) return null;
|
||||
|
||||
const { label, icon: Icon, onClick, busy } = action;
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
disabled
|
||||
aria-hidden
|
||||
tabIndex={-1}
|
||||
sx={{
|
||||
...tileActionSx,
|
||||
right: 48,
|
||||
visibility: 'hidden',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
<SaveOutlinedIcon sx={tileActionIconSx} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-label={label}
|
||||
disabled={busy}
|
||||
sx={{ ...tileActionSx, right: 4 }}
|
||||
>
|
||||
{busy ? (
|
||||
<CircularProgress size={22} color="inherit" />
|
||||
) : (
|
||||
<Icon sx={tileActionIconSx} />
|
||||
)}
|
||||
</IconButton>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
theme,
|
||||
wide = 1,
|
||||
children,
|
||||
sx,
|
||||
minHeight = 120,
|
||||
color,
|
||||
action,
|
||||
elevation = 4,
|
||||
} = this.props;
|
||||
const actionPadding = action ? TILE_ACTION_PADDING : 0;
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={elevation}
|
||||
sx={panelTilePaperSx({
|
||||
wide,
|
||||
sx,
|
||||
minHeight,
|
||||
color,
|
||||
theme,
|
||||
})}
|
||||
>
|
||||
{this.renderAction()}
|
||||
{this.renderTitle()}
|
||||
<Box sx={{ flex: 1, minWidth: 0, pr: actionPadding }}>{children}</Box>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const PanelTile = injectMuiTheme(PanelTileBase);
|
||||
|
||||
export function PanelTileGrid({ children, sx }) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: {
|
||||
xs: '1fr',
|
||||
sm: 'repeat(2, 1fr)',
|
||||
md: `repeat(${GRID_COLUMNS}, 1fr)`,
|
||||
},
|
||||
gap: 2,
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export { GRID_COLUMNS };
|
||||
@@ -1,3 +0,0 @@
|
||||
export { PanelTile, PanelTileGrid, GRID_COLUMNS } from './PanelTile';
|
||||
export { EditablePanelTile } from './EditablePanelTile';
|
||||
export { PENDING_EDIT_BORDER } from './panelTileLayout';
|
||||
@@ -1,13 +0,0 @@
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
|
||||
/** Injects MUI theme into class components (replaces removed withTheme HOC). */
|
||||
export function injectMuiTheme(Wrapped) {
|
||||
function ComponentWithTheme(props) {
|
||||
const theme = useTheme();
|
||||
return <Wrapped {...props} theme={theme} />;
|
||||
}
|
||||
|
||||
const name = Wrapped.displayName || Wrapped.name || 'Component';
|
||||
ComponentWithTheme.displayName = `injectMuiTheme(${name})`;
|
||||
return ComponentWithTheme;
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
const GRID_COLUMNS = 4;
|
||||
|
||||
export const PENDING_EDIT_BORDER = '#e11d48';
|
||||
export const EDITING_TILE_BORDER = '#94a3b8';
|
||||
|
||||
function clampWide(wide) {
|
||||
return Math.min(Math.max(wide, 1), 3);
|
||||
}
|
||||
|
||||
export function responsiveColumnSpan(wide) {
|
||||
const span = clampWide(wide);
|
||||
return {
|
||||
xs: '1 / -1',
|
||||
sm: span >= 2 ? 'span 2' : 'span 1',
|
||||
md: `span ${span}`,
|
||||
};
|
||||
}
|
||||
|
||||
export const PENDING_EDIT_RING_PX = 4;
|
||||
export const TILE_ACTION_PADDING = 12;
|
||||
|
||||
export const tileActionSx = {
|
||||
position: 'absolute',
|
||||
top: 4,
|
||||
color: 'text.secondary',
|
||||
};
|
||||
|
||||
export const tileActionIconSx = {
|
||||
fontSize: 28,
|
||||
};
|
||||
|
||||
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,
|
||||
color,
|
||||
theme,
|
||||
}) {
|
||||
const elevationShadow = theme?.shadows?.[4];
|
||||
const ringColor = pending
|
||||
? PENDING_EDIT_BORDER
|
||||
: editing
|
||||
? EDITING_TILE_BORDER
|
||||
: null;
|
||||
|
||||
return {
|
||||
gridColumn: responsiveColumnSpan(wide),
|
||||
position: 'relative',
|
||||
p: 2,
|
||||
minHeight,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
border: 'none',
|
||||
borderRadius: PANEL_TILE_BORDER_RADIUS,
|
||||
...(color ? { bgcolor: color } : {}),
|
||||
...(ringColor
|
||||
? {
|
||||
boxShadow: tileRingBoxShadow(ringColor, elevationShadow),
|
||||
}
|
||||
: {}),
|
||||
...sx,
|
||||
};
|
||||
}
|
||||
|
||||
export { GRID_COLUMNS };
|
||||
@@ -1,6 +1,6 @@
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { Tile } from './Tile';
|
||||
import { Tile } from 'dashgui/tiles';
|
||||
|
||||
export class ActionTile extends Tile {
|
||||
renderIcon() {
|
||||
|
||||
@@ -2,7 +2,7 @@ import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
|
||||
import Box from '@mui/material/Box';
|
||||
import ButtonBase from '@mui/material/ButtonBase';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { Tile, tileBaseSx } from './Tile';
|
||||
import { Tile, tileBaseSx } from 'dashgui/tiles';
|
||||
import { formatPrice } from '../../utils/price';
|
||||
import { articleSku } from '../../pages/artikel/articleRoute';
|
||||
import ArticlePhoto from '../article/ArticlePhoto';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { Tile, tileBaseSx } from './Tile';
|
||||
import { Tile, tileBaseSx } from 'dashgui/tiles';
|
||||
|
||||
/**
|
||||
* BackTile renders as a <div> (Box) instead of <button> (ButtonBase)
|
||||
|
||||
@@ -13,7 +13,7 @@ import PointOfSaleIcon from '@mui/icons-material/PointOfSale';
|
||||
import PhotoCameraIcon from '@mui/icons-material/PhotoCamera';
|
||||
import ThumbHashImage from '../article/ThumbHashImage';
|
||||
import { thumb } from '../../api';
|
||||
import { tileBaseSx } from './Tile';
|
||||
import { tileBaseSx } from 'dashgui/tiles';
|
||||
|
||||
/**
|
||||
* Top row (1/3): sort handle | settings toggle | move handle.
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
import { Component } from 'react';
|
||||
import ButtonBase from '@mui/material/ButtonBase';
|
||||
import Typography from '@mui/material/Typography';
|
||||
|
||||
export const TILE_SIZE = 140;
|
||||
|
||||
/** Matches panel tile rounding (theme.shape.borderRadius × 4 → typically 16px). */
|
||||
export const TILE_BORDER_RADIUS = 4;
|
||||
|
||||
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',
|
||||
border: 'none',
|
||||
borderRadius: TILE_BORDER_RADIUS,
|
||||
overflow: 'hidden',
|
||||
bgcolor: 'background.paper',
|
||||
boxShadow: 3,
|
||||
transition: 'box-shadow 0.15s, background-color 0.15s',
|
||||
'&:hover': {
|
||||
bgcolor: 'action.hover',
|
||||
boxShadow: 6,
|
||||
},
|
||||
};
|
||||
|
||||
export class Tile extends Component {
|
||||
handleClick = () => {
|
||||
this.props.onClick?.();
|
||||
};
|
||||
|
||||
renderIcon() {
|
||||
return null;
|
||||
}
|
||||
|
||||
renderLabel() {
|
||||
const { label } = this.props;
|
||||
if (!label) return null;
|
||||
return (
|
||||
<Typography variant="body2" fontWeight={600} sx={{ lineHeight: 1.2 }}>
|
||||
{label}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
renderSecondary() {
|
||||
const { secondary } = this.props;
|
||||
if (!secondary) return null;
|
||||
return (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ lineHeight: 1.2 }}>
|
||||
{secondary}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
renderExtra() {
|
||||
return null;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { sx, disabled } = this.props;
|
||||
return (
|
||||
<ButtonBase
|
||||
component="button"
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={this.handleClick}
|
||||
sx={{
|
||||
...tileBaseSx,
|
||||
position: 'relative',
|
||||
...(disabled
|
||||
? { opacity: 0.5, pointerEvents: 'none' }
|
||||
: {}),
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
{this.renderIcon()}
|
||||
{this.renderLabel()}
|
||||
{this.renderSecondary()}
|
||||
{this.renderExtra()}
|
||||
</ButtonBase>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
export { TILE_SIZE, Tile, tileBaseSx } from './Tile';
|
||||
export { TILE_SIZE, Tile, tileBaseSx } from 'dashgui/tiles';
|
||||
export { BackTile } from './BackTile';
|
||||
export { CategoryTile, CategoryEditTile } from './CategoryTile';
|
||||
export { ArticleTile } from './ArticleTile';
|
||||
|
||||
Reference in New Issue
Block a user