695 lines
26 KiB
JavaScript
695 lines
26 KiB
JavaScript
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 ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';
|
|
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
|
|
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 authAutomat from '../../automats/authAutomat';
|
|
import companyAutomat from '../../automats/companyAutomat';
|
|
import categoryAutomat, { whenCategoryTreeReady } from '../../automats/categoryAutomat';
|
|
import { categoryDisplayName } from '../../utils/categoryUtils';
|
|
import { backendErrorFrom } from '../../utils/backendError';
|
|
import { notifyError } from '../../utils/notify';
|
|
|
|
function parseVhostSettings(str) {
|
|
if (!str || typeof str !== 'string') return {};
|
|
const result = {};
|
|
for (const part of str.split(';')) {
|
|
const trimmed = part.trim();
|
|
if (!trimmed) continue;
|
|
const colonIdx = trimmed.indexOf(':');
|
|
if (colonIdx !== -1) {
|
|
const key = trimmed.slice(0, colonIdx).trim().toLowerCase();
|
|
const val = trimmed.slice(colonIdx + 1).trim();
|
|
result[key] = val;
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
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 = companyAutomat.state.terminals ?? [];
|
|
this.state = {
|
|
status: companyAutomat.state.terminalsStatus === 'succeeded' ? 'ready' : 'loading',
|
|
error: null,
|
|
terminals,
|
|
categories: categoryAutomat.state.categories,
|
|
rootCategoryId: categoryAutomat.state.rootCategoryId,
|
|
vhostSettings: authAutomat.state.vhostSettings,
|
|
newTerminalName: '',
|
|
creatingTerminal: false,
|
|
enableAllForNew: true,
|
|
deletingId: null,
|
|
pickerTerminal: null,
|
|
patchingTerminalId: null,
|
|
sortDrafts: {},
|
|
};
|
|
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.handleToggleEnabled = this.handleToggleEnabled.bind(this);
|
|
this.handleSortDraftChange = this.handleSortDraftChange.bind(this);
|
|
this.handleSortSubmit = this.handleSortSubmit.bind(this);
|
|
this.handleMoveTerminal = this.handleMoveTerminal.bind(this);
|
|
this.getCategoryLabel = this.getCategoryLabel.bind(this);
|
|
}
|
|
|
|
componentDidMount() {
|
|
this.unsub = companyAutomat.subscribe(this, (state) => ({
|
|
terminals: Array.isArray(state.terminals) ? state.terminals : [],
|
|
status: state.terminalsStatus === 'succeeded' ? 'ready' : state.terminalsStatus === 'failed' ? 'failed' : this.state.status,
|
|
}));
|
|
this.unsubCategory = categoryAutomat.subscribe(this, (state) => ({
|
|
categories: state.categories,
|
|
rootCategoryId: state.rootCategoryId,
|
|
}));
|
|
this.unsubAuth = authAutomat.subscribe(this, (state) => ({
|
|
vhostSettings: state.vhostSettings,
|
|
}));
|
|
if (categoryAutomat.state.treeStatus === 'idle') {
|
|
void categoryAutomat.actions.loadTree();
|
|
}
|
|
}
|
|
|
|
componentWillUnmount() {
|
|
this.unsub?.();
|
|
this.unsubCategory?.();
|
|
this.unsubAuth?.();
|
|
}
|
|
|
|
async loadTerminals() {
|
|
this.setState({ error: null });
|
|
try {
|
|
const terminals = await companyAutomat.actions.loadTerminals();
|
|
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, terminals } = this.state;
|
|
const trimmed = newTerminalName.trim();
|
|
if (!trimmed) return;
|
|
|
|
this.setState({ creatingTerminal: true });
|
|
try {
|
|
const nextSort = terminals.length > 0
|
|
? Math.max(...terminals.map((t) => Number(t.sort) || 0)) + 1
|
|
: 0;
|
|
const created = await companyAutomat.actions.createTerminal({
|
|
name: trimmed,
|
|
sort: nextSort,
|
|
enabled: true,
|
|
});
|
|
|
|
if (enableAllForNew && created?.id != null) {
|
|
await companyAutomat.actions.enableAllTerminalCategories({ terminalId: created.id });
|
|
await whenCategoryTreeReady();
|
|
}
|
|
|
|
this.setState({ newTerminalName: '' });
|
|
enqueueSnackbar('Terminal hinzugefügt.', { variant: 'success' });
|
|
} catch (err) {
|
|
notifyError(err);
|
|
} finally {
|
|
this.setState({ creatingTerminal: false });
|
|
}
|
|
}
|
|
|
|
async handleToggleEnabled(terminal, nextValue) {
|
|
this.setState({ patchingTerminalId: terminal.id });
|
|
try {
|
|
await companyAutomat.actions.updateTerminal(terminal.id, {
|
|
enabled: Boolean(nextValue),
|
|
});
|
|
enqueueSnackbar(nextValue ? 'Terminal aktiviert.' : 'Terminal deaktiviert.', {
|
|
variant: 'success',
|
|
});
|
|
} catch (err) {
|
|
notifyError(err);
|
|
} finally {
|
|
this.setState({ patchingTerminalId: null });
|
|
}
|
|
}
|
|
|
|
handleSortDraftChange(terminalId, value) {
|
|
this.setState((prev) => ({
|
|
sortDrafts: { ...prev.sortDrafts, [terminalId]: value },
|
|
}));
|
|
}
|
|
|
|
async handleSortSubmit(terminal) {
|
|
const draft = this.state.sortDrafts[terminal.id];
|
|
if (draft === undefined) return;
|
|
const nextSort = Number(draft);
|
|
if (isNaN(nextSort) || nextSort === (terminal.sort ?? 0)) {
|
|
this.setState((prev) => {
|
|
const next = { ...prev.sortDrafts };
|
|
delete next[terminal.id];
|
|
return { sortDrafts: next };
|
|
});
|
|
return;
|
|
}
|
|
this.setState({ patchingTerminalId: terminal.id });
|
|
try {
|
|
await companyAutomat.actions.updateTerminal(terminal.id, { sort: nextSort });
|
|
enqueueSnackbar('Reihenfolge aktualisiert.', { variant: 'success' });
|
|
} catch (err) {
|
|
notifyError(err);
|
|
} finally {
|
|
this.setState((prev) => {
|
|
const next = { ...prev.sortDrafts };
|
|
delete next[terminal.id];
|
|
return { sortDrafts: next, patchingTerminalId: null };
|
|
});
|
|
}
|
|
}
|
|
|
|
async handleMoveTerminal(terminal, direction) {
|
|
const { terminals } = this.state;
|
|
const currentIndex = terminals.findIndex((t) => t.id === terminal.id);
|
|
if (currentIndex === -1) return;
|
|
const targetIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1;
|
|
if (targetIndex < 0 || targetIndex >= terminals.length) return;
|
|
|
|
const other = terminals[targetIndex];
|
|
const currentSort = terminal.sort ?? 0;
|
|
const otherSort = other.sort ?? 0;
|
|
|
|
let newSort;
|
|
if (direction === 'up') {
|
|
newSort = otherSort <= currentSort ? otherSort - 1 : otherSort;
|
|
} else {
|
|
newSort = otherSort >= currentSort ? otherSort + 1 : otherSort;
|
|
}
|
|
|
|
this.setState({ patchingTerminalId: terminal.id });
|
|
try {
|
|
await companyAutomat.actions.updateTerminal(terminal.id, { sort: newSort });
|
|
enqueueSnackbar('Reihenfolge aktualisiert.', { variant: 'success' });
|
|
} catch (err) {
|
|
notifyError(err);
|
|
} finally {
|
|
this.setState({ patchingTerminalId: null });
|
|
}
|
|
}
|
|
|
|
async handleDeleteTerminal(terminal) {
|
|
this.setState({ deletingId: terminal.id });
|
|
try {
|
|
await companyAutomat.actions.deleteTerminal(terminal.id);
|
|
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 companyAutomat.actions.updateTerminal(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 companyAutomat.actions.updateTerminal(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,
|
|
vhostSettings,
|
|
newTerminalName,
|
|
creatingTerminal,
|
|
enableAllForNew,
|
|
deletingId,
|
|
pickerTerminal,
|
|
patchingTerminalId,
|
|
rootCategoryId,
|
|
} = this.state;
|
|
|
|
const parsedVhost = parseVhostSettings(vhostSettings);
|
|
const posIp = parsedVhost.posip || parsedVhost.ip || null;
|
|
const posPort = parsedVhost.posport || parsedVhost.port || null;
|
|
const hasVhostSettings = Boolean(posIp || posPort || vhostSettings);
|
|
|
|
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 }}>
|
|
{hasVhostSettings && (
|
|
<Box
|
|
sx={{
|
|
mb: 2,
|
|
p: 1.5,
|
|
px: 2,
|
|
borderRadius: 2,
|
|
bgcolor: 'action.hover',
|
|
border: '1px solid',
|
|
borderColor: 'divider',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 4,
|
|
flexWrap: 'wrap',
|
|
}}
|
|
>
|
|
<Typography variant="body2" sx={{ color: 'text.secondary', fontWeight: 500 }}>
|
|
Pos Sync IP:{' '}
|
|
<Box component="span" sx={{ color: 'text.primary', fontFamily: 'monospace', fontWeight: 600 }}>
|
|
{posIp ?? '—'}
|
|
</Box>
|
|
</Typography>
|
|
<Typography variant="body2" sx={{ color: 'text.secondary', fontWeight: 500 }}>
|
|
Pos Sync Port:{' '}
|
|
<Box component="span" sx={{ color: 'text.primary', fontFamily: 'monospace', fontWeight: 600 }}>
|
|
{posPort ?? '—'}
|
|
</Box>
|
|
</Typography>
|
|
</Box>
|
|
)}
|
|
<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"
|
|
slotProps={{
|
|
htmlInput: {
|
|
autoComplete: 'off',
|
|
autoCorrect: 'off',
|
|
autoCapitalize: 'off',
|
|
spellCheck: false,
|
|
},
|
|
}}
|
|
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, index) => {
|
|
const isDeleting = deletingId === terminal.id;
|
|
const isPatching = patchingTerminalId === terminal.id;
|
|
const isEnabled = terminal.enabled !== false && terminal.enabled !== 0 && terminal.enabled != null;
|
|
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: isEnabled ? 'divider' : 'action.disabledBackground',
|
|
bgcolor: 'background.paper',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: 1.5,
|
|
opacity: isEnabled ? 1 : 0.72,
|
|
transition: 'opacity 0.2s ease, border-color 0.2s ease',
|
|
}}
|
|
>
|
|
{/* Header: Icon, Name, PIN, Version, Status Switch, 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: isEnabled ? 'primary.main' : 'text.disabled', fontSize: 24 }} />
|
|
<Typography
|
|
variant="subtitle1"
|
|
fontWeight={600}
|
|
sx={{ color: isEnabled ? 'text.primary' : 'text.secondary' }}
|
|
>
|
|
{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}
|
|
{terminal.clientversion ? (
|
|
<Tooltip title="POS Client Software-Version">
|
|
<Chip
|
|
label={`v${terminal.clientversion}`}
|
|
size="small"
|
|
variant="outlined"
|
|
color="info"
|
|
sx={{ fontFamily: 'monospace', fontWeight: 600, fontSize: '0.75rem' }}
|
|
/>
|
|
</Tooltip>
|
|
) : null}
|
|
</Box>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
<Tooltip
|
|
title={
|
|
isEnabled
|
|
? 'Terminal aktiv (kann synchronisieren)'
|
|
: 'Terminal deaktiviert (POS-Zugriff blockiert)'
|
|
}
|
|
>
|
|
<FormControlLabel
|
|
control={
|
|
<Switch
|
|
size="small"
|
|
checked={isEnabled}
|
|
disabled={isPatching || isDeleting}
|
|
onChange={(e) => void this.handleToggleEnabled(terminal, e.target.checked)}
|
|
color="success"
|
|
/>
|
|
}
|
|
label={
|
|
<Typography
|
|
variant="body2"
|
|
sx={{
|
|
fontSize: '0.8125rem',
|
|
fontWeight: 500,
|
|
color: isEnabled ? 'text.primary' : 'text.disabled',
|
|
}}
|
|
>
|
|
{isEnabled ? 'Aktiv' : 'Inaktiv'}
|
|
</Typography>
|
|
}
|
|
sx={{ m: 0 }}
|
|
/>
|
|
</Tooltip>
|
|
<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>
|
|
</Box>
|
|
|
|
{/* Settings row: Root category picker, sort order controls, 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>
|
|
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, flexWrap: 'wrap' }}>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
|
<Typography variant="body2" color="text.secondary">
|
|
Reihenfolge:
|
|
</Typography>
|
|
<Tooltip title="Nach oben verschieben">
|
|
<span>
|
|
<IconButton
|
|
size="small"
|
|
disabled={index === 0 || isPatching || isDeleting}
|
|
onClick={() => void this.handleMoveTerminal(terminal, 'up')}
|
|
sx={{ p: 0.25 }}
|
|
>
|
|
<ArrowUpwardIcon sx={{ fontSize: 16 }} />
|
|
</IconButton>
|
|
</span>
|
|
</Tooltip>
|
|
<Tooltip title="Nach unten verschieben">
|
|
<span>
|
|
<IconButton
|
|
size="small"
|
|
disabled={index === terminals.length - 1 || isPatching || isDeleting}
|
|
onClick={() => void this.handleMoveTerminal(terminal, 'down')}
|
|
sx={{ p: 0.25 }}
|
|
>
|
|
<ArrowDownwardIcon sx={{ fontSize: 16 }} />
|
|
</IconButton>
|
|
</span>
|
|
</Tooltip>
|
|
<TextField
|
|
type="number"
|
|
size="small"
|
|
disabled={isPatching || isDeleting}
|
|
value={this.state.sortDrafts[terminal.id] ?? terminal.sort ?? 0}
|
|
onChange={(e) => this.handleSortDraftChange(terminal.id, e.target.value)}
|
|
onBlur={() => void this.handleSortSubmit(terminal)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
e.target.blur();
|
|
}
|
|
}}
|
|
slotProps={{
|
|
htmlInput: {
|
|
step: 1,
|
|
style: { width: 44, padding: '3px 6px', textAlign: 'center', fontSize: '0.8125rem' },
|
|
},
|
|
}}
|
|
sx={{ width: 56 }}
|
|
/>
|
|
</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>
|
|
</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>
|
|
{terminal.clientversion && (
|
|
<Typography variant="caption" color="text.secondary">
|
|
<strong>POS-Software:</strong> {terminal.clientversion}
|
|
</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>
|
|
);
|
|
}
|
|
}
|