From 243eba5fc63b22ea1a9a7eb7ec536dd0308126ef Mon Sep 17 00:00:00 2001 From: seb Date: Thu, 8 Oct 2026 01:44:40 +0200 Subject: [PATCH] u --- src/api/index.js | 13 + src/automats/posterminalAutomat.js | 16 + src/components/UserMenu.jsx | 35 +- .../settings/CompanySettingsPanel.jsx | 231 ++++++++++ .../settings/PosTerminalSettingsPanel.jsx | 429 ++++++++++++++++++ src/components/settings/SettingsDialog.jsx | 16 +- src/components/settings/index.js | 5 +- 7 files changed, 718 insertions(+), 27 deletions(-) create mode 100644 src/components/settings/CompanySettingsPanel.jsx create mode 100644 src/components/settings/PosTerminalSettingsPanel.jsx diff --git a/src/api/index.js b/src/api/index.js index da7c978..d6ae656 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -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. diff --git a/src/automats/posterminalAutomat.js b/src/automats/posterminalAutomat.js index fa1488d..6efa6ec 100644 --- a/src/automats/posterminalAutomat.js +++ b/src/automats/posterminalAutomat.js @@ -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 diff --git a/src/components/UserMenu.jsx b/src/components/UserMenu.jsx index a4b5a74..87d1592 100644 --- a/src/components/UserMenu.jsx +++ b/src/components/UserMenu.jsx @@ -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 { e.preventDefault()} + onContextMenu={this.handleContextMenu} + onMouseDown={(e) => { + if (e.button === 0) e.preventDefault(); + }} sx={{ p: 0, width: 32, height: 32, borderRadius: '50%' }} > {user?.email} - - - - Unternehmen - - @@ -190,8 +180,7 @@ class UserMenu extends PureComponent { Sign out - - + ); } diff --git a/src/components/settings/CompanySettingsPanel.jsx b/src/components/settings/CompanySettingsPanel.jsx new file mode 100644 index 0000000..8edeeeb --- /dev/null +++ b/src/components/settings/CompanySettingsPanel.jsx @@ -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 ( + + + + ); + } + + if (status === 'failed' && !name) { + return ; + } + + const parsedTaxrate = parseTaxRate(taxrate); + const parsedReducedtaxrate = parseTaxRate(reducedtaxrate); + const canSave = + name.trim() !== '' && + ISO639_OPTIONS.includes(iso639) && + parsedTaxrate != null && + parsedReducedtaxrate != null; + + return ( + + {error ? ( + isBackendFailure(error) ? ( + + ) : ( + + {typeof error === 'string' ? error : error.message} + + ) + ) : null} + + + this.setState({ name: nextName })} + autoComplete="off" + autoFocus + /> + + + Sprache + { + if (value) this.setState({ iso639: value }); + }} + > + Deutsch + English + + + + + this.setState({ taxrate: e.target.value })} + error={taxrate !== '' && parsedTaxrate == null} + slotProps={{ + input: { + endAdornment: %, + }, + htmlInput: { min: 0, max: 100, step: 0.01, required: true }, + }} + /> + this.setState({ reducedtaxrate: e.target.value })} + error={reducedtaxrate !== '' && parsedReducedtaxrate == null} + slotProps={{ + input: { + endAdornment: %, + }, + htmlInput: { min: 0, max: 100, step: 0.01, required: true }, + }} + /> + + + + + + + + ); + } +} diff --git a/src/components/settings/PosTerminalSettingsPanel.jsx b/src/components/settings/PosTerminalSettingsPanel.jsx new file mode 100644 index 0000000..dc05a80 --- /dev/null +++ b/src/components/settings/PosTerminalSettingsPanel.jsx @@ -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 ( + + + + ); + } + + if (status === 'failed' && terminals.length === 0) { + return ; + } + + return ( + + + this.setState({ newTerminalName: e.target.value })} + disabled={creatingTerminal} + autoComplete="off" + 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) => { + 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 ( + + {/* Header: Icon, Name, PIN, Delete action */} + + + + + {terminal.name} + + {terminal.pairingpin ? ( + + ) : ( + + )} + {terminal.clientserial ? ( + + ({terminal.clientserial}) + + ) : null} + + void this.handleDeleteTerminal(terminal)} + disabled={isDeleting || creatingTerminal || isPatching} + color="error" + > + {isDeleting ? ( + + ) : ( + + )} + + + + {/* Settings row: Root category picker 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" + /> + + + + void this.handleToggleSyncRootArticles(terminal, e.target.checked)} + /> + } + label={ + + Root-Artikel synchronisieren + + } + sx={{ m: 0 }} + /> + + + {/* Dates row */} + + + {pairedDateText && ( + + Gekoppelt: {pairedDateText} + + )} + + Zuletzt online: {lastseenText} + + + 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); + }); + }} + /> + )} + + ); + } +} diff --git a/src/components/settings/SettingsDialog.jsx b/src/components/settings/SettingsDialog.jsx index a762caf..6f62bcf 100644 --- a/src/components/settings/SettingsDialog.jsx +++ b/src/components/settings/SettingsDialog.jsx @@ -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: , Panel: CompanySettingsPanel }, { id: TAB_KEYS.keys, label: 'Einstellungen', icon: , Panel: SettingsKeysPanel }, { id: TAB_KEYS.numbers, label: 'Nummernkreise', icon: , Panel: NumberSettingsPanel }, { id: TAB_KEYS.printers, label: 'Drucker', icon: , Panel: PrinterSettingsPanel }, + { id: TAB_KEYS.posterminal, label: 'POS Terminal', icon: , 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.map((t) => ( diff --git a/src/components/settings/index.js b/src/components/settings/index.js index cc6f719..618231e 100644 --- a/src/components/settings/index.js +++ b/src/components/settings/index.js @@ -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';