Compare commits

..

2 Commits

Author SHA1 Message Date
seb
243eba5fc6 u 2026-10-08 01:44:40 +02:00
seb
8bc912c567 u 2026-10-08 01:44:38 +02:00
8 changed files with 718 additions and 367 deletions

View File

@@ -1710,6 +1710,19 @@ export const posterminal = {
});
},
/**
* PATCH /api/posterminal/:id
* Body: { name?: string, rootCategoryId?: number|null, syncRootArticles?: boolean, pairingpin?: string }
* Update posterminal configuration.
* → 200 { posterminal }
*/
update(terminalId, patch) {
return http(`/api/posterminal/${terminalId}`, {
method: 'PATCH',
body: patch,
});
},
/**
* DELETE /api/posterminal/:id → 204
* Deletes a terminal.

View File

@@ -63,6 +63,22 @@ const actions = {
return terminal;
},
/**
* Update a posterminal.
* @param {number} terminalId
* @param {object} patch
*/
async update(terminalId, patch) {
const res = await posterminalApi.update(terminalId, patch);
const updated = res.posterminal;
posterminalAutomat.setState({
terminals: posterminalAutomat.state.terminals.map((t) =>
t.id === terminalId ? { ...t, ...updated } : t
),
});
return updated;
},
/**
* Delete a posterminal.
* @param {number} terminalId

View File

@@ -1,340 +0,0 @@
import { Component } from 'react';
import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import AppDialog from './AppDialog';
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 'dashgui/SingleLineTextEdit';
import companyAutomat from '../automats/companyAutomat';
import posterminalAutomat from '../automats/posterminalAutomat';
import { whenCategoryTreeReady } from '../automats/categoryAutomat';
import { ISO639_OPTIONS, parseTaxRate } from '../utils/companyForm';
import { backendErrorFrom, isBackendFailure } from '../utils/backendError';
import { notifyError } from '../utils/notify';
export default class CompanySettingsDialog extends Component {
constructor(props) {
super(props);
this.state = {
busy: false,
error: null,
name: '',
iso639: 'de',
taxrate: '',
reducedtaxrate: '',
newTerminalName: '',
creatingTerminal: false,
enableAllForNew: true,
terminals: [],
};
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.loadCompany = this.loadCompany.bind(this);
this.loadTerminals = this.loadTerminals.bind(this);
}
componentDidMount() {
if (this.props.open) {
this.setState({ error: null });
void this.loadCompany();
void this.loadTerminals();
}
}
componentDidUpdate(prevProps) {
const { open } = this.props;
if (open && !prevProps.open) {
this.setState({ error: null });
void this.loadCompany();
void this.loadTerminals();
}
}
syncFormFromCompany(companyRow) {
this.setState({
name: companyRow.name,
iso639: companyRow.iso639,
taxrate: companyRow.taxrate != null ? String(companyRow.taxrate) : '',
reducedtaxrate: companyRow.reducedtaxrate != null ? String(companyRow.reducedtaxrate) : '',
});
}
async loadCompany() {
try {
const companyRow = await companyAutomat.actions.load();
this.syncFormFromCompany(companyRow);
} catch (err) {
notifyError(err);
}
}
async loadTerminals() {
try {
const terminals = await posterminalAutomat.actions.load();
this.setState({ terminals: Array.isArray(terminals) ? terminals : [] });
} catch (err) {
notifyError(err);
}
}
async handleCreateTerminal() {
const { newTerminalName, enableAllForNew } = this.state;
const trimmed = newTerminalName.trim();
this.setState({ creatingTerminal: true });
try {
await posterminalAutomat.actions.create({ name: trimmed });
if (enableAllForNew) {
const created = posterminalAutomat.state.terminals.at(-1);
if (created?.id != null) {
await posterminalAutomat.actions.enableAll({ terminalId: created.id });
await whenCategoryTreeReady();
}
}
const terminals = await posterminalAutomat.actions.load();
this.setState({ newTerminalName: '', terminals: Array.isArray(terminals) ? terminals : [] });
} catch (err) {
notifyError(err);
} finally {
this.setState({ creatingTerminal: false });
}
}
async handleDeleteTerminal(terminal) {
try {
await posterminalAutomat.actions.delete(terminal.id);
const terminals = await posterminalAutomat.actions.load();
this.setState({ terminals: Array.isArray(terminals) ? terminals : [] });
} catch (err) {
notifyError(err);
}
}
async handleSubmit(event) {
event.preventDefault();
const { onClose } = this.props;
const { name, iso639, taxrate, reducedtaxrate } = this.state;
const parsedTaxrate = parseTaxRate(taxrate);
const parsedReducedtaxrate = parseTaxRate(reducedtaxrate);
const canSave =
name.trim() !== '' &&
ISO639_OPTIONS.includes(iso639) &&
parsedTaxrate != null &&
parsedReducedtaxrate != null;
if (!canSave) {
this.setState({ error: 'Bitte alle Pflichtfelder ausfüllen.' });
return;
}
this.setState({ busy: true, error: null });
try {
await companyAutomat.actions.update({
name: name.trim(),
iso639,
taxrate: parsedTaxrate,
reducedtaxrate: parsedReducedtaxrate,
});
enqueueSnackbar('Unternehmen gespeichert.', { variant: 'success' });
onClose();
} catch (err) {
this.setState({ error: backendErrorFrom(err) });
} finally {
this.setState({ busy: false });
}
}
render() {
const { open, onClose } = this.props;
const { busy, error, name, iso639, taxrate, reducedtaxrate } = this.state;
const parsedTaxrate = parseTaxRate(taxrate);
const parsedReducedtaxrate = parseTaxRate(reducedtaxrate);
const canSave =
name.trim() !== '' &&
ISO639_OPTIONS.includes(iso639) &&
parsedTaxrate != null &&
parsedReducedtaxrate != null;
return (
<AppDialog open={open} onClose={busy ? undefined : onClose} maxWidth="sm" fullWidth>
<DialogTitle>Unternehmen bearbeiten</DialogTitle>
<Box component="form" onSubmit={this.handleSubmit} noValidate>
<DialogContent>
{error ? (
isBackendFailure(error) ? (
<BackendError error={error} sx={{ mb: 2 }} />
) : (
<Alert severity="error" sx={{ mb: 2 }}>
{typeof error === 'string' ? error : error.message}
</Alert>
)
) : null}
<Stack spacing={2}>
<SingleLineTextEdit
required
size="small"
label="Name"
value={name}
disabled={busy}
onChange={(nextName) => this.setState({ name: nextName })}
autoComplete="off"
autoFocus
/>
<FormControl required>
<FormLabel sx={{ mb: 0.5 }}>Sprache</FormLabel>
<ToggleButtonGroup
exclusive
required
size="small"
value={iso639}
disabled={busy}
onChange={(_event, value) => {
if (value) this.setState({ iso639: value });
}}
>
<ToggleButton value="de">Deutsch</ToggleButton>
<ToggleButton value="en">English</ToggleButton>
</ToggleButtonGroup>
</FormControl>
<Stack direction="row" spacing={1}>
<TextField
fullWidth
required
size="small"
type="number"
label="Normalsatz"
value={taxrate}
disabled={busy}
onChange={(e) => this.setState({ taxrate: e.target.value })}
error={taxrate !== '' && parsedTaxrate == null}
slotProps={{
input: {
endAdornment: <InputAdornment position="end">%</InputAdornment>,
},
htmlInput: { min: 0, max: 100, step: 0.01, required: true },
}}
/>
<TextField
fullWidth
required
size="small"
type="number"
label="Ermäßigter Satz"
value={reducedtaxrate}
disabled={busy}
onChange={(e) => this.setState({ reducedtaxrate: e.target.value })}
error={reducedtaxrate !== '' && parsedReducedtaxrate == null}
slotProps={{
input: {
endAdornment: <InputAdornment position="end">%</InputAdornment>,
},
htmlInput: { min: 0, max: 100, step: 0.01, required: true },
}}
/>
</Stack>
<Divider sx={{ my: 1 }} />
<Box>
<List dense sx={{ py: 0 }}>
{(this.state.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}
autoComplete="off"
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>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button onClick={onClose} disabled={busy}>
Abbrechen
</Button>
<Button type="submit" variant="contained" disabled={busy || !canSave}>
Speichern
</Button>
</DialogActions>
</Box>
</AppDialog>
);
}
}

View File

@@ -8,7 +8,6 @@ import MenuItem from '@mui/material/MenuItem';
import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText';
import LogoutIcon from '@mui/icons-material/Logout';
import BusinessIcon from '@mui/icons-material/Business';
import SettingsIcon from '@mui/icons-material/Settings';
import HelpOutlineRoundedIcon from '@mui/icons-material/HelpOutlineRounded';
import SystemUpdateAltIcon from '@mui/icons-material/SystemUpdateAlt';
@@ -16,7 +15,6 @@ import Switch from '@mui/material/Switch';
import Divider from '@mui/material/Divider';
import authAutomat, { bootstrapAuthMe } from '../automats/authAutomat';
import { helpAutomat, toggleHelp, pwaAutomat, openPwaDialog } from '../automats';
import CompanySettingsDialog from './CompanySettingsDialog';
import SettingsDialog from './settings/SettingsDialog';
import { notifyError } from '../utils/notify';
@@ -26,17 +24,15 @@ class UserMenu extends PureComponent {
this.state = {
anchorEl: null,
settingsOpen: false,
numbersOpen: false,
user: authAutomat.state.user,
helpEnabled: helpAutomat.state?.enabled ?? false,
pwaUpdateAvailable: pwaAutomat.state.needRefresh,
};
this.handleOpenMenu = this.handleOpenMenu.bind(this);
this.handleContextMenu = this.handleContextMenu.bind(this);
this.handleCloseMenu = this.handleCloseMenu.bind(this);
this.handleOpenSettings = this.handleOpenSettings.bind(this);
this.handleCloseSettings = this.handleCloseSettings.bind(this);
this.handleOpenNumbers = this.handleOpenNumbers.bind(this);
this.handleCloseNumbers = this.handleCloseNumbers.bind(this);
this.handleToggleHelp = this.handleToggleHelp.bind(this);
this.handleOpenPwaUpdate = this.handleOpenPwaUpdate.bind(this);
this.handleLogout = this.handleLogout.bind(this);
@@ -67,6 +63,11 @@ class UserMenu extends PureComponent {
this.setState({ anchorEl: event.currentTarget });
}
handleContextMenu(event) {
event?.preventDefault?.();
this.setState({ anchorEl: event.currentTarget });
}
handleCloseMenu() {
this.setState({ anchorEl: null });
}
@@ -79,14 +80,6 @@ class UserMenu extends PureComponent {
this.setState({ settingsOpen: false });
}
handleOpenNumbers() {
this.setState({ anchorEl: null, numbersOpen: true });
}
handleCloseNumbers() {
this.setState({ numbersOpen: false });
}
handleToggleHelp(e) {
e?.preventDefault?.();
e?.stopPropagation?.();
@@ -104,7 +97,7 @@ class UserMenu extends PureComponent {
}
render() {
const { user, anchorEl, settingsOpen, numbersOpen, helpEnabled, pwaUpdateAvailable } = this.state;
const { user, anchorEl, settingsOpen, helpEnabled, pwaUpdateAvailable } = this.state;
const open = Boolean(anchorEl);
const pictureSrc = (() => {
@@ -122,7 +115,10 @@ class UserMenu extends PureComponent {
<Box sx={{ px: 1.5, py: 1.5, alignSelf: 'flex-start' }}>
<IconButton
onClick={this.handleOpenMenu}
onMouseDown={(e) => e.preventDefault()}
onContextMenu={this.handleContextMenu}
onMouseDown={(e) => {
if (e.button === 0) e.preventDefault();
}}
sx={{ p: 0, width: 32, height: 32, borderRadius: '50%' }}
>
<Badge
@@ -159,12 +155,6 @@ class UserMenu extends PureComponent {
)}
<MenuItem disabled>{user?.email}</MenuItem>
<MenuItem disabled={!user?.selectedCompanyId} onClick={this.handleOpenSettings}>
<ListItemIcon>
<BusinessIcon fontSize="small" />
</ListItemIcon>
<ListItemText>Unternehmen</ListItemText>
</MenuItem>
<MenuItem disabled={!user?.selectedCompanyId} onClick={this.handleOpenNumbers}>
<ListItemIcon>
<SettingsIcon fontSize="small" />
</ListItemIcon>
@@ -190,8 +180,7 @@ class UserMenu extends PureComponent {
<ListItemText>Sign out</ListItemText>
</MenuItem>
</Menu>
<CompanySettingsDialog open={settingsOpen} onClose={this.handleCloseSettings} />
<SettingsDialog open={numbersOpen} onClose={this.handleCloseNumbers} />
<SettingsDialog open={settingsOpen} onClose={this.handleCloseSettings} />
</>
);
}

View File

@@ -0,0 +1,231 @@
import { Component } from 'react';
import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import CircularProgress from '@mui/material/CircularProgress';
import FormControl from '@mui/material/FormControl';
import FormLabel from '@mui/material/FormLabel';
import InputAdornment from '@mui/material/InputAdornment';
import Stack from '@mui/material/Stack';
import TextField from '@mui/material/TextField';
import ToggleButton from '@mui/material/ToggleButton';
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
import { enqueueSnackbar } from 'notistack';
import BackendError from '../BackendError';
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
import companyAutomat from '../../automats/companyAutomat';
import { ISO639_OPTIONS, parseTaxRate } from '../../utils/companyForm';
import { backendErrorFrom, isBackendFailure } from '../../utils/backendError';
import { notifyError } from '../../utils/notify';
export default class CompanySettingsPanel extends Component {
constructor(props) {
super(props);
const current = companyAutomat.state.current;
this.state = {
status: current ? 'ready' : 'loading',
busy: false,
error: null,
name: current?.name ?? '',
iso639: current?.iso639 ?? 'de',
taxrate: current?.taxrate != null ? String(current.taxrate) : '',
reducedtaxrate: current?.reducedtaxrate != null ? String(current.reducedtaxrate) : '',
};
this.handleSubmit = this.handleSubmit.bind(this);
this.handleRetry = this.handleRetry.bind(this);
this.loadCompany = this.loadCompany.bind(this);
this.syncFormFromCompany = this.syncFormFromCompany.bind(this);
}
componentDidMount() {
this.unsub = companyAutomat.subscribe(this, (state) => {
if (state.current && this.state.status === 'loading') {
this.syncFormFromCompany(state.current);
}
});
void this.loadCompany();
}
componentWillUnmount() {
this.unsub?.();
}
syncFormFromCompany(companyRow) {
if (!companyRow) return;
this.setState({
name: companyRow.name ?? '',
iso639: companyRow.iso639 ?? 'de',
taxrate: companyRow.taxrate != null ? String(companyRow.taxrate) : '',
reducedtaxrate: companyRow.reducedtaxrate != null ? String(companyRow.reducedtaxrate) : '',
status: 'ready',
});
}
async loadCompany() {
this.setState({ error: null });
try {
const companyRow = await companyAutomat.actions.load();
this.syncFormFromCompany(companyRow);
} catch (err) {
notifyError(err);
this.setState({ status: 'failed', error: backendErrorFrom(err) });
}
}
handleRetry() {
void this.loadCompany();
}
async handleSubmit(event) {
event?.preventDefault?.();
const { name, iso639, taxrate, reducedtaxrate } = this.state;
const parsedTaxrate = parseTaxRate(taxrate);
const parsedReducedtaxrate = parseTaxRate(reducedtaxrate);
const canSave =
name.trim() !== '' &&
ISO639_OPTIONS.includes(iso639) &&
parsedTaxrate != null &&
parsedReducedtaxrate != null;
if (!canSave) {
this.setState({ error: 'Bitte alle Pflichtfelder ausfüllen.' });
return;
}
this.setState({ busy: true, error: null });
try {
await companyAutomat.actions.update({
name: name.trim(),
iso639,
taxrate: parsedTaxrate,
reducedtaxrate: parsedReducedtaxrate,
});
enqueueSnackbar('Unternehmen gespeichert.', { variant: 'success' });
} catch (err) {
this.setState({ error: backendErrorFrom(err) });
} finally {
this.setState({ busy: false });
}
}
render() {
const { status, busy, error, name, iso639, taxrate, reducedtaxrate } = this.state;
if (status === 'loading' && !name) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
<CircularProgress />
</Box>
);
}
if (status === 'failed' && !name) {
return <BackendError error={error} onRetry={this.handleRetry} />;
}
const parsedTaxrate = parseTaxRate(taxrate);
const parsedReducedtaxrate = parseTaxRate(reducedtaxrate);
const canSave =
name.trim() !== '' &&
ISO639_OPTIONS.includes(iso639) &&
parsedTaxrate != null &&
parsedReducedtaxrate != null;
return (
<Box
component="form"
onSubmit={this.handleSubmit}
noValidate
sx={{ maxWidth: 540, mx: 'auto', py: 2 }}
>
{error ? (
isBackendFailure(error) ? (
<BackendError error={error} sx={{ mb: 2 }} />
) : (
<Alert severity="error" sx={{ mb: 2 }}>
{typeof error === 'string' ? error : error.message}
</Alert>
)
) : null}
<Stack spacing={2.5}>
<SingleLineTextEdit
required
size="small"
label="Name"
value={name}
disabled={busy}
onChange={(nextName) => this.setState({ name: nextName })}
autoComplete="off"
autoFocus
/>
<FormControl required>
<FormLabel sx={{ mb: 0.5 }}>Sprache</FormLabel>
<ToggleButtonGroup
exclusive
required
size="small"
value={iso639}
disabled={busy}
onChange={(_event, value) => {
if (value) this.setState({ iso639: value });
}}
>
<ToggleButton value="de">Deutsch</ToggleButton>
<ToggleButton value="en">English</ToggleButton>
</ToggleButtonGroup>
</FormControl>
<Stack direction="row" spacing={2}>
<TextField
fullWidth
required
size="small"
type="number"
label="Normalsatz"
value={taxrate}
disabled={busy}
onChange={(e) => this.setState({ taxrate: e.target.value })}
error={taxrate !== '' && parsedTaxrate == null}
slotProps={{
input: {
endAdornment: <InputAdornment position="end">%</InputAdornment>,
},
htmlInput: { min: 0, max: 100, step: 0.01, required: true },
}}
/>
<TextField
fullWidth
required
size="small"
type="number"
label="Ermäßigter Satz"
value={reducedtaxrate}
disabled={busy}
onChange={(e) => this.setState({ reducedtaxrate: e.target.value })}
error={reducedtaxrate !== '' && parsedReducedtaxrate == null}
slotProps={{
input: {
endAdornment: <InputAdornment position="end">%</InputAdornment>,
},
htmlInput: { min: 0, max: 100, step: 0.01, required: true },
}}
/>
</Stack>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', pt: 1 }}>
<Button
type="submit"
variant="contained"
disabled={busy || !canSave}
startIcon={busy ? <CircularProgress size={16} color="inherit" /> : null}
>
Speichern
</Button>
</Box>
</Stack>
</Box>
);
}
}

View File

@@ -0,0 +1,429 @@
import { Component } from 'react';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Chip from '@mui/material/Chip';
import CircularProgress from '@mui/material/CircularProgress';
import Divider from '@mui/material/Divider';
import FormControlLabel from '@mui/material/FormControlLabel';
import IconButton from '@mui/material/IconButton';
import Switch from '@mui/material/Switch';
import TextField from '@mui/material/TextField';
import Tooltip from '@mui/material/Tooltip';
import Typography from '@mui/material/Typography';
import AddIcon from '@mui/icons-material/Add';
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
import FolderIcon from '@mui/icons-material/Folder';
import PointOfSaleIcon from '@mui/icons-material/PointOfSale';
import { enqueueSnackbar } from 'notistack';
import BackendError from '../BackendError';
import CategoryPickerDialog from '../article/CategoryPickerDialog';
import posterminalAutomat from '../../automats/posterminalAutomat';
import categoryAutomat, { whenCategoryTreeReady } from '../../automats/categoryAutomat';
import { categoryDisplayName } from '../../utils/categoryUtils';
import { backendErrorFrom } from '../../utils/backendError';
import { notifyError } from '../../utils/notify';
function formatDateTime(val) {
if (!val) return null;
try {
const d = new Date(val);
if (isNaN(d.getTime())) return null;
return d.toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
} catch {
return null;
}
}
export default class PosTerminalSettingsPanel extends Component {
constructor(props) {
super(props);
const terminals = posterminalAutomat.state.terminals ?? [];
this.state = {
status: posterminalAutomat.state.status === 'succeeded' ? 'ready' : 'loading',
error: null,
terminals,
categories: categoryAutomat.state.categories,
rootCategoryId: categoryAutomat.state.rootCategoryId,
newTerminalName: '',
creatingTerminal: false,
enableAllForNew: true,
deletingId: null,
pickerTerminal: null,
patchingTerminalId: null,
};
this.handleRetry = this.handleRetry.bind(this);
this.loadTerminals = this.loadTerminals.bind(this);
this.handleCreateTerminal = this.handleCreateTerminal.bind(this);
this.handleDeleteTerminal = this.handleDeleteTerminal.bind(this);
this.handleSetRootCategory = this.handleSetRootCategory.bind(this);
this.handleToggleSyncRootArticles = this.handleToggleSyncRootArticles.bind(this);
this.getCategoryLabel = this.getCategoryLabel.bind(this);
}
componentDidMount() {
this.unsub = posterminalAutomat.subscribe(this, (state) => ({
terminals: Array.isArray(state.terminals) ? state.terminals : [],
status: state.status === 'succeeded' ? 'ready' : state.status === 'failed' ? 'failed' : this.state.status,
}));
this.unsubCategory = categoryAutomat.subscribe(this, (state) => ({
categories: state.categories,
rootCategoryId: state.rootCategoryId,
}));
if (categoryAutomat.state.treeStatus === 'idle') {
void categoryAutomat.actions.loadTree();
}
void this.loadTerminals();
}
componentWillUnmount() {
this.unsub?.();
this.unsubCategory?.();
}
async loadTerminals() {
this.setState({ error: null });
try {
const terminals = await posterminalAutomat.actions.load();
this.setState({
status: 'ready',
terminals: Array.isArray(terminals) ? terminals : [],
});
} catch (err) {
notifyError(err);
this.setState({ status: 'failed', error: backendErrorFrom(err) });
}
}
handleRetry() {
void this.loadTerminals();
}
async handleCreateTerminal() {
const { newTerminalName, enableAllForNew } = this.state;
const trimmed = newTerminalName.trim();
if (!trimmed) return;
this.setState({ creatingTerminal: true });
try {
const created = await posterminalAutomat.actions.create({ name: trimmed });
if (enableAllForNew && created?.id != null) {
await posterminalAutomat.actions.enableAll({ terminalId: created.id });
await whenCategoryTreeReady();
}
await posterminalAutomat.actions.load();
this.setState({ newTerminalName: '' });
enqueueSnackbar('Terminal hinzugefügt.', { variant: 'success' });
} catch (err) {
notifyError(err);
} finally {
this.setState({ creatingTerminal: false });
}
}
async handleDeleteTerminal(terminal) {
this.setState({ deletingId: terminal.id });
try {
await posterminalAutomat.actions.delete(terminal.id);
await posterminalAutomat.actions.load();
enqueueSnackbar('Terminal gelöscht.', { variant: 'info' });
} catch (err) {
notifyError(err);
} finally {
this.setState({ deletingId: null });
}
}
async handleSetRootCategory(terminal, categoryId) {
this.setState({ patchingTerminalId: terminal.id });
try {
await posterminalAutomat.actions.update(terminal.id, {
rootCategoryId: categoryId != null ? Number(categoryId) : null,
});
enqueueSnackbar('Root-Kategorie aktualisiert.', { variant: 'success' });
} catch (err) {
notifyError(err);
} finally {
this.setState({ patchingTerminalId: null });
}
}
async handleToggleSyncRootArticles(terminal, nextValue) {
this.setState({ patchingTerminalId: terminal.id });
try {
await posterminalAutomat.actions.update(terminal.id, {
syncRootArticles: Boolean(nextValue),
});
enqueueSnackbar('Synchronisierungseinstellung gespeichert.', { variant: 'success' });
} catch (err) {
notifyError(err);
} finally {
this.setState({ patchingTerminalId: null });
}
}
getCategoryLabel(terminal) {
const { categories, rootCategoryId } = this.state;
if (terminal.rootCategoryId == null) {
return 'Standard (Home)';
}
const node = categories?.[String(terminal.rootCategoryId)];
if (!node) {
return `Kategorie #${terminal.rootCategoryId}`;
}
return categoryDisplayName(node.name, node.id, rootCategoryId) || `Kategorie #${terminal.rootCategoryId}`;
}
render() {
const {
status,
error,
terminals,
newTerminalName,
creatingTerminal,
enableAllForNew,
deletingId,
pickerTerminal,
patchingTerminalId,
rootCategoryId,
} = this.state;
if (status === 'loading' && terminals.length === 0) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
<CircularProgress />
</Box>
);
}
if (status === 'failed' && terminals.length === 0) {
return <BackendError error={error} onRetry={this.handleRetry} />;
}
return (
<Box sx={{ maxWidth: 680, mx: 'auto', py: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<TextField
size="small"
placeholder="Neues Terminal..."
value={newTerminalName}
onChange={(e) => this.setState({ newTerminalName: e.target.value })}
disabled={creatingTerminal}
autoComplete="off"
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
void this.handleCreateTerminal();
}
}}
sx={{ flex: 1 }}
/>
<Tooltip title="Alle Kategorien für dieses Terminal aktivieren">
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<PointOfSaleIcon sx={{ fontSize: 18, color: 'text.secondary' }} />
<Switch
size="small"
checked={enableAllForNew}
onChange={(e) => this.setState({ enableAllForNew: e.target.checked })}
disabled={creatingTerminal}
/>
</Box>
</Tooltip>
<Button
variant="contained"
size="small"
startIcon={creatingTerminal ? <CircularProgress size={16} color="inherit" /> : <AddIcon />}
onClick={this.handleCreateTerminal}
disabled={creatingTerminal || !newTerminalName.trim()}
>
Hinzufügen
</Button>
</Box>
<Divider sx={{ mb: 2 }} />
{terminals.length === 0 ? (
<Box sx={{ textAlign: 'center', py: 6, color: 'text.secondary' }}>
<PointOfSaleIcon sx={{ fontSize: 48, mb: 1, opacity: 0.5 }} />
<Typography variant="body2">Keine POS Terminals angelegt.</Typography>
</Box>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{terminals.map((terminal) => {
const isDeleting = deletingId === terminal.id;
const isPatching = patchingTerminalId === terminal.id;
const pairedDateText = formatDateTime(terminal.pairedAt);
const lastseenText = formatDateTime(terminal.lastseenAt) ?? 'Nie';
const updatedText = formatDateTime(terminal.updatedAt) ?? '—';
return (
<Box
key={terminal.id}
sx={{
p: 2,
borderRadius: 2,
border: '1px solid',
borderColor: 'divider',
bgcolor: 'background.paper',
display: 'flex',
flexDirection: 'column',
gap: 1.5,
}}
>
{/* Header: Icon, Name, PIN, Delete action */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flexWrap: 'wrap' }}>
<PointOfSaleIcon sx={{ color: 'primary.main', fontSize: 24 }} />
<Typography variant="subtitle1" fontWeight={600}>
{terminal.name}
</Typography>
{terminal.pairingpin ? (
<Chip
label={`PIN: ${terminal.pairingpin}`}
size="small"
variant="outlined"
sx={{ fontFamily: 'monospace', fontWeight: 600, fontSize: '0.75rem' }}
/>
) : (
<Chip
label="Gekoppelt"
size="small"
variant="outlined"
color="success"
sx={{ fontWeight: 600, fontSize: '0.75rem' }}
/>
)}
{terminal.clientserial ? (
<Typography variant="caption" color="text.secondary" sx={{ fontFamily: 'monospace' }}>
({terminal.clientserial})
</Typography>
) : null}
</Box>
<IconButton
size="small"
aria-label={`Terminal ${terminal.name} löschen`}
onClick={() => void this.handleDeleteTerminal(terminal)}
disabled={isDeleting || creatingTerminal || isPatching}
color="error"
>
{isDeleting ? (
<CircularProgress size={16} color="inherit" />
) : (
<DeleteOutlinedIcon fontSize="small" />
)}
</IconButton>
</Box>
{/* Settings row: Root category picker and syncRootArticles toggle */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: 2,
pt: 0.5,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Typography variant="body2" color="text.secondary">
Root-Kategorie:
</Typography>
<Chip
icon={<FolderIcon />}
label={this.getCategoryLabel(terminal)}
onClick={() => this.setState({ pickerTerminal: terminal })}
onDelete={
terminal.rootCategoryId != null
? () => void this.handleSetRootCategory(terminal, null)
: undefined
}
clickable
variant={terminal.rootCategoryId != null ? 'filled' : 'outlined'}
color={terminal.rootCategoryId != null ? 'primary' : 'default'}
size="small"
/>
<Button
size="small"
variant="text"
onClick={() => this.setState({ pickerTerminal: terminal })}
sx={{ minWidth: 'auto', p: 0.5, fontSize: '0.75rem' }}
>
Ändern
</Button>
</Box>
<FormControlLabel
control={
<Switch
size="small"
checked={Boolean(terminal.syncRootArticles)}
disabled={isPatching || isDeleting}
onChange={(e) => void this.handleToggleSyncRootArticles(terminal, e.target.checked)}
/>
}
label={
<Typography variant="body2">
Root-Artikel synchronisieren
</Typography>
}
sx={{ m: 0 }}
/>
</Box>
{/* Dates row */}
<Divider sx={{ my: 0.25 }} />
<Box
sx={{
display: 'flex',
alignItems: 'center',
flexWrap: 'wrap',
gap: 2.5,
color: 'text.secondary',
}}
>
{pairedDateText && (
<Typography variant="caption" color="text.secondary">
<strong>Gekoppelt:</strong> {pairedDateText}
</Typography>
)}
<Typography variant="caption" color="text.secondary">
<strong>Zuletzt online:</strong> {lastseenText}
</Typography>
<Typography variant="caption" color="text.secondary">
<strong>Aktualisiert:</strong> {updatedText}
</Typography>
</Box>
</Box>
);
})}
</Box>
)}
{pickerTerminal && (
<CategoryPickerDialog
open={Boolean(pickerTerminal)}
onClose={() => this.setState({ pickerTerminal: null })}
mode="single"
title={`Root-Kategorie für „${pickerTerminal.name}“`}
confirmLabel="Übernehmen"
selectedId={pickerTerminal.rootCategoryId ?? rootCategoryId}
onSelectCategory={(targetId) => {
const term = pickerTerminal;
this.setState({ pickerTerminal: null }, () => {
void this.handleSetRootCategory(term, targetId);
});
}}
/>
)}
</Box>
);
}
}

View File

@@ -8,35 +8,43 @@ import Tabs from '@mui/material/Tabs';
import NumbersIcon from '@mui/icons-material/Numbers';
import PrintOutlinedIcon from '@mui/icons-material/PrintOutlined';
import TuneIcon from '@mui/icons-material/Tune';
import BusinessIcon from '@mui/icons-material/Business';
import PointOfSaleIcon from '@mui/icons-material/PointOfSale';
import CloseIcon from '@mui/icons-material/Close';
import IconButton from '@mui/material/IconButton';
import CompanySettingsPanel from './CompanySettingsPanel';
import NumberSettingsPanel from './NumberSettingsPanel';
import PrinterSettingsPanel from './PrinterSettingsPanel';
import SettingsKeysPanel from './SettingsKeysPanel';
import PosTerminalSettingsPanel from './PosTerminalSettingsPanel';
const TAB_KEYS = {
company: 'company',
keys: 'keys',
numbers: 'numbers',
printers: 'printers',
posterminal: 'posterminal',
};
const TABS = [
{ id: TAB_KEYS.company, label: 'Unternehmen', icon: <BusinessIcon />, Panel: CompanySettingsPanel },
{ id: TAB_KEYS.keys, label: 'Einstellungen', icon: <TuneIcon />, Panel: SettingsKeysPanel },
{ id: TAB_KEYS.numbers, label: 'Nummernkreise', icon: <NumbersIcon />, Panel: NumberSettingsPanel },
{ id: TAB_KEYS.printers, label: 'Drucker', icon: <PrintOutlinedIcon />, Panel: PrinterSettingsPanel },
{ id: TAB_KEYS.posterminal, label: 'POS Terminal', icon: <PointOfSaleIcon />, Panel: PosTerminalSettingsPanel },
];
export default class SettingsDialog extends Component {
constructor(props) {
super(props);
this.state = { tab: TAB_KEYS.keys };
this.state = { tab: TAB_KEYS.company };
this.handleTabChange = this.handleTabChange.bind(this);
}
componentDidUpdate(prevProps) {
const { open } = this.props;
if (!open && prevProps.open) {
this.setState({ tab: TAB_KEYS.keys });
this.setState({ tab: TAB_KEYS.company });
}
}
@@ -87,7 +95,9 @@ export default class SettingsDialog extends Component {
<Tabs
value={tab}
onChange={this.handleTabChange}
variant="fullWidth"
variant="scrollable"
scrollButtons="auto"
allowScrollButtonsMobile
sx={{ px: 3 }}
>
{TABS.map((t) => (

View File

@@ -1,3 +1,6 @@
export { default as SettingsDialog } from './SettingsDialog';
export { default as CompanySettingsPanel } from './CompanySettingsPanel';
export { default as SettingsKeysPanel } from './SettingsKeysPanel';
export { default as NumberSettingsPanel } from './NumberSettingsPanel';export { default as PrinterSettingsPanel } from './PrinterSettingsPanel';
export { default as NumberSettingsPanel } from './NumberSettingsPanel';
export { default as PrinterSettingsPanel } from './PrinterSettingsPanel';
export { default as PosTerminalSettingsPanel } from './PosTerminalSettingsPanel';