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:
seb
2026-08-08 16:17:16 +02:00
parent 2cbe5dabb2
commit 57fff72f12
38 changed files with 80 additions and 759 deletions

View File

@@ -27,6 +27,19 @@ To test the full PWA precache (production build + preview, no HMR), use
Dashback must be running and your Google account must be authorized on the
backend.
## Shared GUI library (`dashgui`)
Reusable custom GUI class components live in the **`dashgui`** package
(`../dashgui`), installed here as a `file:` dependency (npm workspace symlink)
so source imports are reflected instantly.
- `dashgui/panels` — `PanelTile`, `PanelTileGrid`, `EditablePanelTile`, layout helpers
- `dashgui/tiles` — base `Tile` (extended by `src/components/tiles/*`)
- `dashgui/SingleLineTextEdit`, `dashgui/ElevatedPill`
See `../dashgui/README.md` for details and the dev/mock-page harness used for
future test cases.
## Auth flow
1. On boot the app calls `GET /api/auth/me` with `credentials: 'include'`.

26
package-lock.json generated
View File

@@ -16,6 +16,7 @@
"@mui/material": "^9.2.0",
"@reduxjs/toolkit": "^2.12.0",
"@shopify/draggable": "^1.2.1",
"dashgui": "file:../dashgui",
"notistack": "^3.0.2",
"react": "^19.2.8",
"react-dom": "^19.2.8",
@@ -31,6 +32,27 @@
"vite-plugin-pwa": "^1.3.0"
}
},
"../dashgui": {
"version": "0.1.0",
"devDependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^9.2.0",
"@mui/material": "^9.2.0",
"@vitejs/plugin-react": "6.0.2",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"vite": "^8.2.0"
},
"peerDependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^9.0.0",
"@mui/material": "^9.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
},
"node_modules/@apideck/better-ajv-errors": {
"version": "0.3.7",
"resolved": "https://registry.npmjs.org/@apideck/better-ajv-errors/-/better-ajv-errors-0.3.7.tgz",
@@ -3575,6 +3597,10 @@
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
"node_modules/dashgui": {
"resolved": "../dashgui",
"link": true
},
"node_modules/data-view-buffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",

View File

@@ -23,6 +23,7 @@
"@reduxjs/toolkit": "^2.12.0",
"@shopify/draggable": "^1.2.1",
"notistack": "^3.0.2",
"dashgui": "file:../dashgui",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-redux": "^9.3.0",

View File

@@ -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';

View File

@@ -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;

View File

@@ -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';
/**

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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';

View File

@@ -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';

View File

@@ -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 {

View File

@@ -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>}

View File

@@ -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} />}

View File

@@ -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 {

View File

@@ -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={

View File

@@ -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 }) => (

View File

@@ -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 {

View File

@@ -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';

View File

@@ -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 {

View File

@@ -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';

View File

@@ -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';

View File

@@ -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);

View File

@@ -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 };

View File

@@ -1,3 +0,0 @@
export { PanelTile, PanelTileGrid, GRID_COLUMNS } from './PanelTile';
export { EditablePanelTile } from './EditablePanelTile';
export { PENDING_EDIT_BORDER } from './panelTileLayout';

View File

@@ -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;
}

View File

@@ -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 };

View File

@@ -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() {

View File

@@ -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';

View File

@@ -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)

View File

@@ -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.

View File

@@ -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>
);
}
}

View File

@@ -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';

View File

@@ -16,8 +16,8 @@ import {
BackTile,
CategoryEditTile,
CategoryTile,
TILE_SIZE,
} from '../../components/tiles';
import { TILE_SIZE } from 'dashgui/tiles';
import withCategoryRoute from './withCategoryRoute';
import { artikelDetailPath, articleSku } from './articleRoute';
import { selectCompanyId } from '../../store/companySlice';

View File

@@ -9,8 +9,8 @@ import Typography from '@mui/material/Typography';
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
import { enqueueSnackbar } from 'notistack';
import BackendError from '../../components/BackendError';
import SingleLineTextEdit from '../../components/SingleLineTextEdit';
import { PanelTile, PanelTileGrid } from '../../components/panels';
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
import { PanelTile, PanelTileGrid } from 'dashgui/panels';
import { StorageManagerContext } from '../../api/storageManagerContext';
import { requireArray, requireDefined, requireObject } from '../../utils/apiShape';
import { backendErrorFrom } from '../../utils/backendError';

View File

@@ -13,8 +13,8 @@ import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
import { enqueueSnackbar } from 'notistack';
import BackendError from '../../components/BackendError';
import SingleLineTextEdit from '../../components/SingleLineTextEdit';
import { PanelTile, PanelTileGrid } from '../../components/panels';
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
import { PanelTile, PanelTileGrid } from 'dashgui/panels';
import { StorageManagerContext } from '../../api/storageManagerContext';
import { requireArray, requireDefined, requireObject } from '../../utils/apiShape';
import { backendErrorFrom } from '../../utils/backendError';

View File

@@ -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 '../../components/BackendError';
import { PanelTile, PanelTileGrid } from '../../components/panels';
import { PanelTile, PanelTileGrid } from 'dashgui/panels';
import { StorageManagerContext } from '../../api/storageManagerContext';
import { router } from '../../appRouter';
import { backendErrorFrom } from '../../utils/backendError';

View File

@@ -6,8 +6,8 @@ import Select from '@mui/material/Select';
import Stack from '@mui/material/Stack';
import TextField from '@mui/material/TextField';
import { enqueueSnackbar } from 'notistack';
import SingleLineTextEdit from '../../components/SingleLineTextEdit';
import { PanelTile, PanelTileGrid } from '../../components/panels';
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
import { PanelTile, PanelTileGrid } from 'dashgui/panels';
import { StorageManagerContext } from '../../api/storageManagerContext';
import { router } from '../../appRouter';
import { requireDefined, requireObject } from '../../utils/apiShape';

View File

@@ -11,8 +11,8 @@ import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
import StorefrontIcon from '@mui/icons-material/Storefront';
import { enqueueSnackbar } from 'notistack';
import BackendError from '../../components/BackendError';
import SingleLineTextEdit from '../../components/SingleLineTextEdit';
import { PanelTile, PanelTileGrid } from '../../components/panels';
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
import { PanelTile, PanelTileGrid } from 'dashgui/panels';
import { StorageManagerContext } from '../../api/storageManagerContext';
import { requireDefined, requireObject } from '../../utils/apiShape';
import { backendErrorFrom } from '../../utils/backendError';