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 ( ); } if (status === 'failed' && terminals.length === 0) { return ; } return ( {hasVhostSettings && ( Pos Sync IP:{' '} {posIp ?? '—'} Pos Sync Port:{' '} {posPort ?? '—'} )} 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 }} /> this.setState({ enableAllForNew: e.target.checked })} disabled={creatingTerminal} /> {terminals.length === 0 ? ( Keine POS Terminals angelegt. ) : ( {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 ( {/* Header: Icon, Name, PIN, Version, Status Switch, Delete action */} {terminal.name} {terminal.pairingpin ? ( ) : ( )} {terminal.clientserial ? ( ({terminal.clientserial}) ) : null} {terminal.clientversion ? ( ) : null} void this.handleToggleEnabled(terminal, e.target.checked)} color="success" /> } label={ {isEnabled ? 'Aktiv' : 'Inaktiv'} } sx={{ m: 0 }} /> void this.handleDeleteTerminal(terminal)} disabled={isDeleting || creatingTerminal || isPatching} color="error" > {isDeleting ? ( ) : ( )} {/* Settings row: Root category picker, sort order controls, and syncRootArticles toggle */} Root-Kategorie: } 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" /> Reihenfolge: void this.handleMoveTerminal(terminal, 'up')} sx={{ p: 0.25 }} > void this.handleMoveTerminal(terminal, 'down')} sx={{ p: 0.25 }} > 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 }} /> void this.handleToggleSyncRootArticles(terminal, e.target.checked)} /> } label={ Root-Artikel synchronisieren } sx={{ m: 0 }} /> {/* Dates row */} {pairedDateText && ( Gekoppelt: {pairedDateText} )} Zuletzt online: {lastseenText} {terminal.clientversion && ( POS-Software: {terminal.clientversion} )} Aktualisiert: {updatedText} ); })} )} {pickerTerminal && ( 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); }); }} /> )} ); } }