u
This commit is contained in:
@@ -8,13 +8,22 @@ 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 Divider from '@mui/material/Divider';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import FormLabel from '@mui/material/FormLabel';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import InputAdornment from '@mui/material/InputAdornment';
|
||||
import List from '@mui/material/List';
|
||||
import ListItem from '@mui/material/ListItem';
|
||||
import ListItemSecondaryAction from '@mui/material/ListItemSecondaryAction';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Switch from '@mui/material/Switch';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import ToggleButton from '@mui/material/ToggleButton';
|
||||
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
|
||||
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';
|
||||
@@ -25,8 +34,18 @@ import {
|
||||
selectCurrentCompany,
|
||||
updateCompany as updateCompanyThunk,
|
||||
} from '../store/companySlice';
|
||||
import {
|
||||
loadPosterminals,
|
||||
createPosterminal,
|
||||
deletePosterminal,
|
||||
enableAllTerminalCategories,
|
||||
selectPosterminals,
|
||||
selectPosterminalStatus,
|
||||
} from '../store/posterminalSlice';
|
||||
import { loadCategoryTree } from '../store/categorySlice';
|
||||
import { ISO639_OPTIONS, parseTaxRate } from '../utils/companyForm';
|
||||
import { backendErrorFrom, isBackendFailure } from '../utils/backendError';
|
||||
import { notifyError } from '../utils/notify';
|
||||
|
||||
class CompanySettingsDialog extends Component {
|
||||
constructor(props) {
|
||||
@@ -38,9 +57,15 @@ class CompanySettingsDialog extends Component {
|
||||
iso639: 'de',
|
||||
taxrate: '',
|
||||
reducedtaxrate: '',
|
||||
newTerminalName: '',
|
||||
creatingTerminal: false,
|
||||
enableAllForNew: true,
|
||||
};
|
||||
this.handleSubmit = this.handleSubmit.bind(this);
|
||||
this.syncFormFromCompany = this.syncFormFromCompany.bind(this);
|
||||
this.handleCreateTerminal = this.handleCreateTerminal.bind(this);
|
||||
this.handleDeleteTerminal = this.handleDeleteTerminal.bind(this);
|
||||
this.handleEnableAllCategories = this.handleEnableAllCategories.bind(this);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
@@ -52,6 +77,10 @@ class CompanySettingsDialog extends Component {
|
||||
} else if (companyStatus !== 'loading') {
|
||||
void this.props.loadCurrentCompany();
|
||||
}
|
||||
// Load posterminals when dialog opens
|
||||
if (this.props.posterminalStatus !== 'loading') {
|
||||
void this.props.loadPosterminals();
|
||||
}
|
||||
}
|
||||
if (open && company && company !== prevProps.company) {
|
||||
this.syncFormFromCompany(company);
|
||||
@@ -67,6 +96,56 @@ class CompanySettingsDialog extends Component {
|
||||
});
|
||||
}
|
||||
|
||||
async handleCreateTerminal() {
|
||||
const { newTerminalName, enableAllForNew } = this.state;
|
||||
const trimmed = newTerminalName.trim();
|
||||
if (!trimmed) {
|
||||
enqueueSnackbar('Terminalname ist erforderlich.', { variant: 'error' });
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ creatingTerminal: true });
|
||||
try {
|
||||
const terminal = await this.props.createPosterminal({ name: trimmed }).unwrap();
|
||||
|
||||
// If "enable all" toggle is on, enable all categories for this terminal
|
||||
if (enableAllForNew && terminal.id) {
|
||||
await this.props.enableAllTerminalCategories({ terminalId: terminal.id }).unwrap();
|
||||
// Reload category tree so tiles reflect the updated posterminalIds
|
||||
this.props.loadCategoryTree();
|
||||
}
|
||||
|
||||
this.setState({ newTerminalName: '' });
|
||||
} catch (err) {
|
||||
notifyError(err);
|
||||
} finally {
|
||||
this.setState({ creatingTerminal: false });
|
||||
}
|
||||
}
|
||||
|
||||
async handleDeleteTerminal(terminal) {
|
||||
try {
|
||||
await this.props.deletePosterminal({ terminalId: terminal.id }).unwrap();
|
||||
} catch (err) {
|
||||
notifyError(err);
|
||||
}
|
||||
}
|
||||
|
||||
async handleEnableAllCategories(terminal) {
|
||||
try {
|
||||
const hasCategories = terminal.categoryIds && terminal.categoryIds.length > 0;
|
||||
if (hasCategories) {
|
||||
await this.props.disableAllTerminalCategories({ terminalId: terminal.id }).unwrap();
|
||||
enqueueSnackbar('Alle Kategorien für Terminal deaktiviert.', { variant: 'success' });
|
||||
} else {
|
||||
await this.props.enableAllTerminalCategories({ terminalId: terminal.id }).unwrap();
|
||||
enqueueSnackbar('Alle Kategorien für Terminal aktiviert.', { variant: 'success' });
|
||||
}
|
||||
} catch (err) {
|
||||
notifyError(err);
|
||||
}
|
||||
}
|
||||
|
||||
async handleSubmit(event) {
|
||||
event.preventDefault();
|
||||
const { onClose } = this.props;
|
||||
@@ -200,6 +279,77 @@ class CompanySettingsDialog extends Component {
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Divider sx={{ my: 1 }} />
|
||||
|
||||
<Box>
|
||||
{this.props.posterminalStatus === 'loading' ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 2 }}>
|
||||
<CircularProgress size={24} />
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
<List dense sx={{ py: 0 }}>
|
||||
{this.props.terminals.map((terminal) => (
|
||||
<ListItem
|
||||
key={terminal.id}
|
||||
sx={{ px: 0, py: 0.5 }}
|
||||
>
|
||||
<PointOfSaleIcon sx={{ mr: 1, color: 'text.secondary', fontSize: 20 }} />
|
||||
<ListItemText
|
||||
primary={terminal.name}
|
||||
secondary={`PIN: ${terminal.pairingpin}`}
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<IconButton
|
||||
edge="end"
|
||||
size="small"
|
||||
onClick={() => this.handleDeleteTerminal(terminal)}
|
||||
disabled={busy || this.state.creatingTerminal}
|
||||
>
|
||||
<DeleteOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
|
||||
<Box sx={{ mt: 1, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<TextField
|
||||
size="small"
|
||||
placeholder="Neues Terminal..."
|
||||
value={this.state.newTerminalName}
|
||||
onChange={(e) => this.setState({ newTerminalName: e.target.value })}
|
||||
disabled={busy || this.state.creatingTerminal}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
this.handleCreateTerminal();
|
||||
}
|
||||
}}
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<PointOfSaleIcon sx={{ fontSize: 18, color: 'text.secondary' }} />
|
||||
<Switch
|
||||
size="small"
|
||||
checked={this.state.enableAllForNew}
|
||||
onChange={(e) => this.setState({ enableAllForNew: e.target.checked })}
|
||||
disabled={busy || this.state.creatingTerminal}
|
||||
/>
|
||||
</Box>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={this.handleCreateTerminal}
|
||||
disabled={busy || this.state.creatingTerminal || !this.state.newTerminalName.trim()}
|
||||
>
|
||||
Hinzufügen
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
)}
|
||||
</DialogContent>
|
||||
@@ -221,11 +371,18 @@ const mapStateToProps = (state) => ({
|
||||
user: selectAuthUser(state),
|
||||
company: selectCurrentCompany(state),
|
||||
companyStatus: selectCompanyStatus(state),
|
||||
terminals: selectPosterminals(state),
|
||||
posterminalStatus: selectPosterminalStatus(state),
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
loadCurrentCompany: () => dispatch(loadCurrentCompany()),
|
||||
updateCompany: (payload) => dispatch(updateCompanyThunk(payload)),
|
||||
loadPosterminals: () => dispatch(loadPosterminals()),
|
||||
createPosterminal: (payload) => dispatch(createPosterminal(payload)),
|
||||
deletePosterminal: (payload) => dispatch(deletePosterminal(payload)),
|
||||
enableAllTerminalCategories: (payload) => dispatch(enableAllTerminalCategories(payload)),
|
||||
loadCategoryTree: () => dispatch(loadCategoryTree()),
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(CompanySettingsDialog);
|
||||
|
||||
@@ -2,18 +2,22 @@ import { Component } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Switch from '@mui/material/Switch';
|
||||
import DragHandleIcon from '@mui/icons-material/DragHandle';
|
||||
import AccountTreeIcon from '@mui/icons-material/AccountTree';
|
||||
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
||||
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
|
||||
import { Tile, tileBaseSx } from './Tile';
|
||||
import SettingsIcon from '@mui/icons-material/Settings';
|
||||
import PointOfSaleIcon from '@mui/icons-material/PointOfSale';
|
||||
import { tileBaseSx } from './Tile';
|
||||
|
||||
/**
|
||||
* Top edit band: left = reorder handle (SortableJS), right = drop-into-subcategory
|
||||
* handle (Shopify Draggable Droppable).
|
||||
* Top row (1/3): sort handle | settings toggle | move handle.
|
||||
*/
|
||||
function TopBand() {
|
||||
function TopBand({ onSettingsClick, settingsTab, terminalCount }) {
|
||||
// settingsTab >= 0 means we're on a terminal tab
|
||||
const isTerminalMode = settingsTab >= 0 && settingsTab < terminalCount;
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
@@ -36,6 +40,20 @@ function TopBand() {
|
||||
>
|
||||
<DragHandleIcon sx={{ fontSize: 36 }} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
aria-label="Einstellungen"
|
||||
disableRipple
|
||||
onClick={onSettingsClick}
|
||||
sx={{
|
||||
color: isTerminalMode ? 'primary.main' : 'action.active',
|
||||
}}
|
||||
>
|
||||
{isTerminalMode ? (
|
||||
<PointOfSaleIcon sx={{ fontSize: 28 }} />
|
||||
) : (
|
||||
<SettingsIcon sx={{ fontSize: 28 }} />
|
||||
)}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
className="droppable-handle"
|
||||
aria-label="In Unterkategorie ablegen"
|
||||
@@ -49,10 +67,48 @@ function TopBand() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared grey name band spanning the middle third.
|
||||
* Middle row (1/3): gray band — TextField or terminal toggle.
|
||||
*/
|
||||
function NameBand({ label, nameInput }) {
|
||||
if (label == null) return null;
|
||||
function MiddleBand({ settingsTab, nameInput, terminals, categoryPosterminalIds, onTerminalToggle }) {
|
||||
console.log('[MiddleBand] settingsTab:', settingsTab, 'terminals:', terminals?.length, 'categoryPosterminalIds:', categoryPosterminalIds);
|
||||
// Show terminal-specific toggle when on a terminal tab
|
||||
if (settingsTab >= 0 && settingsTab < terminals.length) {
|
||||
const terminal = terminals[settingsTab];
|
||||
const isEnabled = categoryPosterminalIds.includes(terminal.id);
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: '33.33%',
|
||||
bottom: '33.33%',
|
||||
left: 0,
|
||||
right: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bgcolor: 'grey.300',
|
||||
px: 1,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<PointOfSaleIcon sx={{ fontSize: 20, color: 'text.secondary' }} />
|
||||
<Typography
|
||||
variant="caption"
|
||||
noWrap
|
||||
sx={{ maxWidth: 80, color: 'text.secondary' }}
|
||||
>
|
||||
{terminal.name}
|
||||
</Typography>
|
||||
<Switch
|
||||
checked={isEnabled}
|
||||
onChange={(e) => onTerminalToggle?.(terminal.id, e.target.checked)}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
@@ -68,24 +124,17 @@ function NameBand({ label, nameInput }) {
|
||||
px: 1,
|
||||
}}
|
||||
>
|
||||
{nameInput ?? (
|
||||
<Typography
|
||||
variant="body2"
|
||||
fontWeight={600}
|
||||
noWrap
|
||||
sx={{ lineHeight: 1.2, color: 'text.primary' }}
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
)}
|
||||
{nameInput}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bottom edit band with left/right chevron reorder buttons and a center delete.
|
||||
* Bottom row (1/3): split into two sub-rows (each 1/6 of tile).
|
||||
* Upper: click zone → navigate into category.
|
||||
* Lower: gray band — left chevron | delete | right chevron.
|
||||
*/
|
||||
function EditBand({ onMoveLeft, onMoveRight, onDelete, canLeft, canRight }) {
|
||||
function BottomRow({ onOpen, editBand }) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
@@ -93,23 +142,67 @@ function EditBand({ onMoveLeft, onMoveRight, onDelete, canLeft, canRight }) {
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '16.66%',
|
||||
height: '33.33%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
px: 0.5,
|
||||
bgcolor: 'grey.400',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<IconButton className="sortable-handle-btn" size="small" aria-label="Nach links verschieben" disabled={!canLeft} onClick={onMoveLeft}>
|
||||
<ChevronLeftIcon sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
<IconButton className="sortable-handle-btn" size="small" aria-label="Löschen" onClick={onDelete}>
|
||||
<DeleteOutlinedIcon sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
<IconButton className="sortable-handle-btn" size="small" aria-label="Nach rechts verschieben" disabled={!canRight} onClick={onMoveRight}>
|
||||
<ChevronRightIcon sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
{/* Upper half: click-through to open category */}
|
||||
<Box
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onOpen}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onOpen?.();
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
flex: 1,
|
||||
cursor: 'pointer',
|
||||
'&:hover': { bgcolor: 'action.hover' },
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Lower half: reorder & delete */}
|
||||
<Box
|
||||
sx={{
|
||||
height: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
px: 0.5,
|
||||
bgcolor: 'grey.400',
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
className="sortable-handle-btn"
|
||||
size="small"
|
||||
aria-label="Nach links verschieben"
|
||||
disabled={!editBand.canLeft}
|
||||
onClick={editBand.onMoveLeft}
|
||||
>
|
||||
<ChevronLeftIcon sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
className="sortable-handle-btn"
|
||||
size="small"
|
||||
aria-label="Löschen"
|
||||
onClick={editBand.onDelete}
|
||||
>
|
||||
<DeleteOutlinedIcon sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
className="sortable-handle-btn"
|
||||
size="small"
|
||||
aria-label="Nach rechts verschieben"
|
||||
disabled={!editBand.canRight}
|
||||
onClick={editBand.onMoveRight}
|
||||
>
|
||||
<ChevronRightIcon sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -117,24 +210,105 @@ function EditBand({ onMoveLeft, onMoveRight, onDelete, canLeft, canRight }) {
|
||||
/**
|
||||
* Category tile (normal mode): no icon, click navigates to the category.
|
||||
*/
|
||||
export class CategoryTile extends Tile {
|
||||
renderIcon() {
|
||||
return null;
|
||||
}
|
||||
export class CategoryTile extends Component {
|
||||
handleClick = () => {
|
||||
this.props.onClick?.();
|
||||
};
|
||||
|
||||
renderLabel() {
|
||||
const { label, nameInput } = this.props;
|
||||
return <NameBand label={label} nameInput={nameInput} />;
|
||||
if (label == null) return null;
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: '33.33%',
|
||||
bottom: '33.33%',
|
||||
left: 0,
|
||||
right: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bgcolor: 'grey.300',
|
||||
px: 1,
|
||||
}}
|
||||
>
|
||||
{nameInput ?? (
|
||||
<Typography
|
||||
variant="body2"
|
||||
fontWeight={600}
|
||||
noWrap
|
||||
sx={{ lineHeight: 1.2, color: 'text.primary' }}
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { sx, onClick: _onClick, label, ...rest } = this.props;
|
||||
return (
|
||||
<Box
|
||||
component="button"
|
||||
type="button"
|
||||
onClick={this.handleClick}
|
||||
{...rest}
|
||||
sx={{
|
||||
...tileBaseSx,
|
||||
position: 'relative',
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
{this.renderLabel()}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Category tile for edit mode: no ButtonBase (no ripple, no click navigation),
|
||||
* always shows the bottom edit band with chevron reorder/delete actions.
|
||||
* Category tile for edit mode: three rows of 1/3 each.
|
||||
*
|
||||
* Top row: sort-handle | settings toggle | drop-handle
|
||||
* Middle row: gray band — TextField or POS toggle
|
||||
* Bottom row: upper half = open-category click zone
|
||||
* lower half = gray band with chevrons + delete
|
||||
*/
|
||||
export class CategoryEditTile extends Component {
|
||||
state = { settingsTab: -1 }; // -1 = rename, 0..n-1 = terminal index
|
||||
|
||||
handleSettingsToggle = () => {
|
||||
const { terminals } = this.props;
|
||||
const terminalCount = terminals?.length || 0;
|
||||
this.setState((prev) => {
|
||||
const next = prev.settingsTab + 1;
|
||||
const nextTab = next > terminalCount ? -1 : next;
|
||||
console.log('[CategoryEditTile] settings toggle', {
|
||||
terminals: terminals?.length,
|
||||
terminalCount,
|
||||
from: prev.settingsTab,
|
||||
to: nextTab,
|
||||
});
|
||||
return { settingsTab: nextTab };
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const { label, nameInput, editBand, sx, ...rest } = this.props;
|
||||
const {
|
||||
label,
|
||||
nameInput,
|
||||
editBand,
|
||||
onOpen,
|
||||
terminals,
|
||||
categoryPosterminalIds,
|
||||
onTerminalToggle,
|
||||
hasTerminals: _hasTerminals,
|
||||
sx,
|
||||
...rest
|
||||
} = this.props;
|
||||
const { settingsTab } = this.state;
|
||||
|
||||
return (
|
||||
<Box
|
||||
{...rest}
|
||||
@@ -146,15 +320,19 @@ export class CategoryEditTile extends Component {
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
<TopBand />
|
||||
<NameBand label={label} nameInput={nameInput} />
|
||||
<EditBand
|
||||
onMoveLeft={editBand.onMoveLeft}
|
||||
onMoveRight={editBand.onMoveRight}
|
||||
onDelete={editBand.onDelete}
|
||||
canLeft={editBand.canLeft}
|
||||
canRight={editBand.canRight}
|
||||
<TopBand
|
||||
onSettingsClick={this.handleSettingsToggle}
|
||||
settingsTab={settingsTab}
|
||||
terminalCount={terminals?.length || 0}
|
||||
/>
|
||||
<MiddleBand
|
||||
settingsTab={settingsTab}
|
||||
nameInput={nameInput}
|
||||
terminals={terminals}
|
||||
categoryPosterminalIds={categoryPosterminalIds}
|
||||
onTerminalToggle={onTerminalToggle}
|
||||
/>
|
||||
<BottomRow onOpen={onOpen} editBand={editBand} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user