u
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user