u
This commit is contained in:
@@ -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
|
* DELETE /api/posterminal/:id → 204
|
||||||
* Deletes a terminal.
|
* Deletes a terminal.
|
||||||
|
|||||||
@@ -63,6 +63,22 @@ const actions = {
|
|||||||
return terminal;
|
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.
|
* Delete a posterminal.
|
||||||
* @param {number} terminalId
|
* @param {number} terminalId
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import MenuItem from '@mui/material/MenuItem';
|
|||||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||||
import ListItemText from '@mui/material/ListItemText';
|
import ListItemText from '@mui/material/ListItemText';
|
||||||
import LogoutIcon from '@mui/icons-material/Logout';
|
import LogoutIcon from '@mui/icons-material/Logout';
|
||||||
import BusinessIcon from '@mui/icons-material/Business';
|
|
||||||
import SettingsIcon from '@mui/icons-material/Settings';
|
import SettingsIcon from '@mui/icons-material/Settings';
|
||||||
import HelpOutlineRoundedIcon from '@mui/icons-material/HelpOutlineRounded';
|
import HelpOutlineRoundedIcon from '@mui/icons-material/HelpOutlineRounded';
|
||||||
import SystemUpdateAltIcon from '@mui/icons-material/SystemUpdateAlt';
|
import SystemUpdateAltIcon from '@mui/icons-material/SystemUpdateAlt';
|
||||||
@@ -16,7 +15,6 @@ import Switch from '@mui/material/Switch';
|
|||||||
import Divider from '@mui/material/Divider';
|
import Divider from '@mui/material/Divider';
|
||||||
import authAutomat, { bootstrapAuthMe } from '../automats/authAutomat';
|
import authAutomat, { bootstrapAuthMe } from '../automats/authAutomat';
|
||||||
import { helpAutomat, toggleHelp, pwaAutomat, openPwaDialog } from '../automats';
|
import { helpAutomat, toggleHelp, pwaAutomat, openPwaDialog } from '../automats';
|
||||||
import CompanySettingsDialog from './CompanySettingsDialog';
|
|
||||||
import SettingsDialog from './settings/SettingsDialog';
|
import SettingsDialog from './settings/SettingsDialog';
|
||||||
import { notifyError } from '../utils/notify';
|
import { notifyError } from '../utils/notify';
|
||||||
|
|
||||||
@@ -26,17 +24,15 @@ class UserMenu extends PureComponent {
|
|||||||
this.state = {
|
this.state = {
|
||||||
anchorEl: null,
|
anchorEl: null,
|
||||||
settingsOpen: false,
|
settingsOpen: false,
|
||||||
numbersOpen: false,
|
|
||||||
user: authAutomat.state.user,
|
user: authAutomat.state.user,
|
||||||
helpEnabled: helpAutomat.state?.enabled ?? false,
|
helpEnabled: helpAutomat.state?.enabled ?? false,
|
||||||
pwaUpdateAvailable: pwaAutomat.state.needRefresh,
|
pwaUpdateAvailable: pwaAutomat.state.needRefresh,
|
||||||
};
|
};
|
||||||
this.handleOpenMenu = this.handleOpenMenu.bind(this);
|
this.handleOpenMenu = this.handleOpenMenu.bind(this);
|
||||||
|
this.handleContextMenu = this.handleContextMenu.bind(this);
|
||||||
this.handleCloseMenu = this.handleCloseMenu.bind(this);
|
this.handleCloseMenu = this.handleCloseMenu.bind(this);
|
||||||
this.handleOpenSettings = this.handleOpenSettings.bind(this);
|
this.handleOpenSettings = this.handleOpenSettings.bind(this);
|
||||||
this.handleCloseSettings = this.handleCloseSettings.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.handleToggleHelp = this.handleToggleHelp.bind(this);
|
||||||
this.handleOpenPwaUpdate = this.handleOpenPwaUpdate.bind(this);
|
this.handleOpenPwaUpdate = this.handleOpenPwaUpdate.bind(this);
|
||||||
this.handleLogout = this.handleLogout.bind(this);
|
this.handleLogout = this.handleLogout.bind(this);
|
||||||
@@ -67,6 +63,11 @@ class UserMenu extends PureComponent {
|
|||||||
this.setState({ anchorEl: event.currentTarget });
|
this.setState({ anchorEl: event.currentTarget });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleContextMenu(event) {
|
||||||
|
event?.preventDefault?.();
|
||||||
|
this.setState({ anchorEl: event.currentTarget });
|
||||||
|
}
|
||||||
|
|
||||||
handleCloseMenu() {
|
handleCloseMenu() {
|
||||||
this.setState({ anchorEl: null });
|
this.setState({ anchorEl: null });
|
||||||
}
|
}
|
||||||
@@ -79,14 +80,6 @@ class UserMenu extends PureComponent {
|
|||||||
this.setState({ settingsOpen: false });
|
this.setState({ settingsOpen: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
handleOpenNumbers() {
|
|
||||||
this.setState({ anchorEl: null, numbersOpen: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
handleCloseNumbers() {
|
|
||||||
this.setState({ numbersOpen: false });
|
|
||||||
}
|
|
||||||
|
|
||||||
handleToggleHelp(e) {
|
handleToggleHelp(e) {
|
||||||
e?.preventDefault?.();
|
e?.preventDefault?.();
|
||||||
e?.stopPropagation?.();
|
e?.stopPropagation?.();
|
||||||
@@ -104,7 +97,7 @@ class UserMenu extends PureComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { user, anchorEl, settingsOpen, numbersOpen, helpEnabled, pwaUpdateAvailable } = this.state;
|
const { user, anchorEl, settingsOpen, helpEnabled, pwaUpdateAvailable } = this.state;
|
||||||
const open = Boolean(anchorEl);
|
const open = Boolean(anchorEl);
|
||||||
|
|
||||||
const pictureSrc = (() => {
|
const pictureSrc = (() => {
|
||||||
@@ -122,7 +115,10 @@ class UserMenu extends PureComponent {
|
|||||||
<Box sx={{ px: 1.5, py: 1.5, alignSelf: 'flex-start' }}>
|
<Box sx={{ px: 1.5, py: 1.5, alignSelf: 'flex-start' }}>
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={this.handleOpenMenu}
|
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%' }}
|
sx={{ p: 0, width: 32, height: 32, borderRadius: '50%' }}
|
||||||
>
|
>
|
||||||
<Badge
|
<Badge
|
||||||
@@ -159,12 +155,6 @@ class UserMenu extends PureComponent {
|
|||||||
)}
|
)}
|
||||||
<MenuItem disabled>{user?.email}</MenuItem>
|
<MenuItem disabled>{user?.email}</MenuItem>
|
||||||
<MenuItem disabled={!user?.selectedCompanyId} onClick={this.handleOpenSettings}>
|
<MenuItem disabled={!user?.selectedCompanyId} onClick={this.handleOpenSettings}>
|
||||||
<ListItemIcon>
|
|
||||||
<BusinessIcon fontSize="small" />
|
|
||||||
</ListItemIcon>
|
|
||||||
<ListItemText>Unternehmen</ListItemText>
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem disabled={!user?.selectedCompanyId} onClick={this.handleOpenNumbers}>
|
|
||||||
<ListItemIcon>
|
<ListItemIcon>
|
||||||
<SettingsIcon fontSize="small" />
|
<SettingsIcon fontSize="small" />
|
||||||
</ListItemIcon>
|
</ListItemIcon>
|
||||||
@@ -190,8 +180,7 @@ class UserMenu extends PureComponent {
|
|||||||
<ListItemText>Sign out</ListItemText>
|
<ListItemText>Sign out</ListItemText>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
</Menu>
|
</Menu>
|
||||||
<CompanySettingsDialog open={settingsOpen} onClose={this.handleCloseSettings} />
|
<SettingsDialog open={settingsOpen} onClose={this.handleCloseSettings} />
|
||||||
<SettingsDialog open={numbersOpen} onClose={this.handleCloseNumbers} />
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
231
src/components/settings/CompanySettingsPanel.jsx
Normal file
231
src/components/settings/CompanySettingsPanel.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
429
src/components/settings/PosTerminalSettingsPanel.jsx
Normal file
429
src/components/settings/PosTerminalSettingsPanel.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,35 +8,43 @@ import Tabs from '@mui/material/Tabs';
|
|||||||
import NumbersIcon from '@mui/icons-material/Numbers';
|
import NumbersIcon from '@mui/icons-material/Numbers';
|
||||||
import PrintOutlinedIcon from '@mui/icons-material/PrintOutlined';
|
import PrintOutlinedIcon from '@mui/icons-material/PrintOutlined';
|
||||||
import TuneIcon from '@mui/icons-material/Tune';
|
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 CloseIcon from '@mui/icons-material/Close';
|
||||||
import IconButton from '@mui/material/IconButton';
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import CompanySettingsPanel from './CompanySettingsPanel';
|
||||||
import NumberSettingsPanel from './NumberSettingsPanel';
|
import NumberSettingsPanel from './NumberSettingsPanel';
|
||||||
import PrinterSettingsPanel from './PrinterSettingsPanel';
|
import PrinterSettingsPanel from './PrinterSettingsPanel';
|
||||||
import SettingsKeysPanel from './SettingsKeysPanel';
|
import SettingsKeysPanel from './SettingsKeysPanel';
|
||||||
|
import PosTerminalSettingsPanel from './PosTerminalSettingsPanel';
|
||||||
|
|
||||||
const TAB_KEYS = {
|
const TAB_KEYS = {
|
||||||
|
company: 'company',
|
||||||
keys: 'keys',
|
keys: 'keys',
|
||||||
numbers: 'numbers',
|
numbers: 'numbers',
|
||||||
printers: 'printers',
|
printers: 'printers',
|
||||||
|
posterminal: 'posterminal',
|
||||||
};
|
};
|
||||||
|
|
||||||
const TABS = [
|
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.keys, label: 'Einstellungen', icon: <TuneIcon />, Panel: SettingsKeysPanel },
|
||||||
{ id: TAB_KEYS.numbers, label: 'Nummernkreise', icon: <NumbersIcon />, Panel: NumberSettingsPanel },
|
{ id: TAB_KEYS.numbers, label: 'Nummernkreise', icon: <NumbersIcon />, Panel: NumberSettingsPanel },
|
||||||
{ id: TAB_KEYS.printers, label: 'Drucker', icon: <PrintOutlinedIcon />, Panel: PrinterSettingsPanel },
|
{ 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 {
|
export default class SettingsDialog extends Component {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = { tab: TAB_KEYS.keys };
|
this.state = { tab: TAB_KEYS.company };
|
||||||
this.handleTabChange = this.handleTabChange.bind(this);
|
this.handleTabChange = this.handleTabChange.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate(prevProps) {
|
componentDidUpdate(prevProps) {
|
||||||
const { open } = this.props;
|
const { open } = this.props;
|
||||||
if (!open && prevProps.open) {
|
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
|
<Tabs
|
||||||
value={tab}
|
value={tab}
|
||||||
onChange={this.handleTabChange}
|
onChange={this.handleTabChange}
|
||||||
variant="fullWidth"
|
variant="scrollable"
|
||||||
|
scrollButtons="auto"
|
||||||
|
allowScrollButtonsMobile
|
||||||
sx={{ px: 3 }}
|
sx={{ px: 3 }}
|
||||||
>
|
>
|
||||||
{TABS.map((t) => (
|
{TABS.map((t) => (
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
export { default as SettingsDialog } from './SettingsDialog';
|
export { default as SettingsDialog } from './SettingsDialog';
|
||||||
|
export { default as CompanySettingsPanel } from './CompanySettingsPanel';
|
||||||
export { default as SettingsKeysPanel } from './SettingsKeysPanel';
|
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';
|
||||||
|
|||||||
Reference in New Issue
Block a user