u
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -4,3 +4,4 @@ dist-ssr
|
|||||||
*.local
|
*.local
|
||||||
.DS_Store
|
.DS_Store
|
||||||
certs/
|
certs/
|
||||||
|
.dashfront-token
|
||||||
177
src/api/api-tester.mjs
Normal file
177
src/api/api-tester.mjs
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* API Tester CLI — reuses src/api/index.js directly.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node src/api/api-tester.mjs # interactive REPL
|
||||||
|
* node src/api/api-tester.mjs <method> [args...] # one-shot call
|
||||||
|
*
|
||||||
|
* Auth:
|
||||||
|
* 1. Copy your session cookie from the browser DevTools (name: "token", value: the JWT)
|
||||||
|
* 2. Paste it into ~/.dashfront-token (or pass --token-file <path>)
|
||||||
|
* 3. Or pass --token <jwt-string> on the command line
|
||||||
|
*
|
||||||
|
* Examples:
|
||||||
|
* node src/api/api-tester.mjs posterminal.list
|
||||||
|
* node src/api/api-tester.mjs category.getTree
|
||||||
|
* node src/api/api-tester.mjs posterminal.create '{"name":"Kassa 3"}'
|
||||||
|
* node src/api/api-tester.mjs auth.me
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { createInterface } from 'node:readline';
|
||||||
|
|
||||||
|
// ── CLI args ─────────────────────────────────────────────────────────────────
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
|
||||||
|
function flag(name) {
|
||||||
|
const idx = args.indexOf(name);
|
||||||
|
if (idx === -1) return null;
|
||||||
|
const val = args[idx + 1];
|
||||||
|
args.splice(idx, 2);
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE_URL = flag('--base') || 'http://localhost:4208';
|
||||||
|
const tokenFileArg = flag('--token-file');
|
||||||
|
const tokenArg = flag('--token');
|
||||||
|
|
||||||
|
function loadToken() {
|
||||||
|
if (tokenArg) return tokenArg;
|
||||||
|
|
||||||
|
const paths = [
|
||||||
|
tokenFileArg,
|
||||||
|
resolve(homedir(), '.dashfront-token'),
|
||||||
|
'./.dashfront-token',
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
for (const p of paths) {
|
||||||
|
try {
|
||||||
|
const t = readFileSync(p, 'utf-8').trim();
|
||||||
|
if (t) return t;
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('No token found. Options:');
|
||||||
|
console.error(' 1. Place JWT in ~/.dashfront-token');
|
||||||
|
console.error(' 2. Pass --token <jwt>');
|
||||||
|
console.error(' 3. Pass --token-file <path>');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOKEN = loadToken();
|
||||||
|
|
||||||
|
// ── Patch globals so src/api/index.js works in Node ──────────────────────────
|
||||||
|
|
||||||
|
// Fake browser globals so src/api/index.js works in Node
|
||||||
|
globalThis.window = {
|
||||||
|
location: { origin: BASE_URL },
|
||||||
|
};
|
||||||
|
globalThis.document = {
|
||||||
|
get cookie() { return ''; },
|
||||||
|
set cookie(_v) { /* no-op */ },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Patch fetch to send the session cookie
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
globalThis.fetch = async (input, init) => {
|
||||||
|
const url = typeof input === 'string' ? input : input?.url ?? String(input);
|
||||||
|
const fullUrl = url.startsWith('http') ? url : `${BASE_URL}${url}`;
|
||||||
|
|
||||||
|
const headers = new Headers(init?.headers);
|
||||||
|
headers.set('Cookie', `token=${TOKEN}`);
|
||||||
|
|
||||||
|
const res = await originalFetch(fullUrl, { ...init, headers });
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Load the API module ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const api = (await import('./index.js')).default;
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const flatten = (obj, prefix = '') => {
|
||||||
|
const entries = [];
|
||||||
|
for (const [key, val] of Object.entries(obj)) {
|
||||||
|
const path = prefix ? `${prefix}.${key}` : key;
|
||||||
|
if (typeof val === 'function') {
|
||||||
|
entries.push([path, val]);
|
||||||
|
} else if (typeof val === 'object' && val !== null) {
|
||||||
|
entries.push(...flatten(val, path));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
};
|
||||||
|
|
||||||
|
const methods = new Map(flatten(api));
|
||||||
|
|
||||||
|
function parseArg(raw) {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (trimmed === 'undefined') return undefined;
|
||||||
|
if (trimmed === 'null') return null;
|
||||||
|
if (trimmed === 'true') return true;
|
||||||
|
if (trimmed === 'false') return false;
|
||||||
|
try { return JSON.parse(trimmed); } catch { return trimmed; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function invoke(methodPath, argStrings = []) {
|
||||||
|
const fn = methods.get(methodPath);
|
||||||
|
if (!fn) {
|
||||||
|
console.error(`Unknown method: ${methodPath}`);
|
||||||
|
console.error(`Available: ${[...methods.keys()].join(', ')}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = argStrings.map(parseArg);
|
||||||
|
|
||||||
|
return fn(...args)
|
||||||
|
.then((result) => {
|
||||||
|
console.log(JSON.stringify(result, null, 2));
|
||||||
|
return result;
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error(`[ERROR] ${err.message}`);
|
||||||
|
if (err.status) console.error(` status: ${err.status}`);
|
||||||
|
if (err.data) console.error(` data:`, err.data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── One-shot mode ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if (args.length > 0) {
|
||||||
|
const [method, ...rest] = args;
|
||||||
|
await invoke(method, rest);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Interactive REPL ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
console.log(`API Tester — ${BASE_URL}`);
|
||||||
|
console.log(`Token: ${TOKEN.slice(0, 20)}…`);
|
||||||
|
console.log(`Methods: ${[...methods.keys()].join(', ')}`);
|
||||||
|
console.log('Type a method call, or "quit" to exit.\n');
|
||||||
|
|
||||||
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||||
|
|
||||||
|
function prompt() {
|
||||||
|
rl.question('api> ', async (line) => {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed === 'quit' || trimmed === 'exit') {
|
||||||
|
rl.close();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse: method arg1 arg2 ...
|
||||||
|
// Arguments are space-separated; wrap objects in quotes: method '{"key":"val"}'
|
||||||
|
const parts = trimmed.match(/(?:[^\s"]+|"[^"]*")+/g) || [];
|
||||||
|
const [method, ...rest] = parts.map((p) => p.replace(/^"|"$/g, ''));
|
||||||
|
|
||||||
|
await invoke(method, rest);
|
||||||
|
prompt();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt();
|
||||||
@@ -371,6 +371,20 @@ export const category = {
|
|||||||
body: { parentId },
|
body: { parentId },
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/category/:id
|
||||||
|
* Body: { pos: boolean } — toggles the POS (Point of Sale) flag.
|
||||||
|
* → 200 { category: { id, pos } }
|
||||||
|
*
|
||||||
|
* PROPOSED — backend needs to implement this field.
|
||||||
|
*/
|
||||||
|
updatePos(categoryId, pos) {
|
||||||
|
return http(`/api/category/${categoryId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { pos },
|
||||||
|
});
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -437,6 +451,83 @@ export const company = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const posterminal = {
|
||||||
|
/**
|
||||||
|
* GET /api/posterminal → { posterminals: [{ id, name, token, pairingpin }] }
|
||||||
|
* List all terminals for the selected company.
|
||||||
|
*/
|
||||||
|
list() {
|
||||||
|
return http('/api/posterminal');
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/posterminal
|
||||||
|
* Body: { name: string }
|
||||||
|
* Creates a new terminal (auto-generates 6-digit pin).
|
||||||
|
* → 200 { posterminal: { id, name, token, pairingpin } }
|
||||||
|
*/
|
||||||
|
create({ name }) {
|
||||||
|
return http('/api/posterminal', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: { name },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/posterminal/:id → 204
|
||||||
|
* Deletes a terminal.
|
||||||
|
*/
|
||||||
|
delete(terminalId) {
|
||||||
|
return http(`/api/posterminal/${terminalId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/posterminal/:id/category/:categoryId
|
||||||
|
* Enable a category on a terminal.
|
||||||
|
* → 200 { id: number, categoryIds: number[] }
|
||||||
|
*/
|
||||||
|
enableCategory(terminalId, categoryId) {
|
||||||
|
return http(`/api/posterminal/${terminalId}/category/${categoryId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/posterminal/:id/category/:categoryId
|
||||||
|
* Disable a category on a terminal.
|
||||||
|
* → 200 { id: number, categoryIds: number[] }
|
||||||
|
*/
|
||||||
|
disableCategory(terminalId, categoryId) {
|
||||||
|
return http(`/api/posterminal/${terminalId}/category/${categoryId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/posterminal/:id/all
|
||||||
|
* Enable all categories on a terminal.
|
||||||
|
* → 200 { id: number, categoryIds: number[] }
|
||||||
|
*/
|
||||||
|
enableAllCategories(terminalId) {
|
||||||
|
return http(`/api/posterminal/${terminalId}/all`, {
|
||||||
|
method: 'PUT',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/posterminal/:id/all
|
||||||
|
* Disable all categories on a terminal.
|
||||||
|
* → 200 { id: number, categoryIds: number[] }
|
||||||
|
*/
|
||||||
|
disableAllCategories(terminalId) {
|
||||||
|
return http(`/api/posterminal/${terminalId}/all`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const auth = {
|
export const auth = {
|
||||||
async me() {
|
async me() {
|
||||||
try {
|
try {
|
||||||
@@ -488,4 +579,4 @@ export const auth = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default { auth, article, brand, category, company, thumb, unit };
|
export default { auth, article, brand, category, company, posterminal, thumb, unit };
|
||||||
|
|||||||
@@ -8,13 +8,22 @@ import Dialog from '@mui/material/Dialog';
|
|||||||
import DialogActions from '@mui/material/DialogActions';
|
import DialogActions from '@mui/material/DialogActions';
|
||||||
import DialogContent from '@mui/material/DialogContent';
|
import DialogContent from '@mui/material/DialogContent';
|
||||||
import DialogTitle from '@mui/material/DialogTitle';
|
import DialogTitle from '@mui/material/DialogTitle';
|
||||||
|
import Divider from '@mui/material/Divider';
|
||||||
import FormControl from '@mui/material/FormControl';
|
import FormControl from '@mui/material/FormControl';
|
||||||
import FormLabel from '@mui/material/FormLabel';
|
import FormLabel from '@mui/material/FormLabel';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
import InputAdornment from '@mui/material/InputAdornment';
|
import InputAdornment from '@mui/material/InputAdornment';
|
||||||
|
import List from '@mui/material/List';
|
||||||
|
import ListItem from '@mui/material/ListItem';
|
||||||
|
import ListItemSecondaryAction from '@mui/material/ListItemSecondaryAction';
|
||||||
|
import ListItemText from '@mui/material/ListItemText';
|
||||||
import Stack from '@mui/material/Stack';
|
import Stack from '@mui/material/Stack';
|
||||||
|
import Switch from '@mui/material/Switch';
|
||||||
import TextField from '@mui/material/TextField';
|
import TextField from '@mui/material/TextField';
|
||||||
import ToggleButton from '@mui/material/ToggleButton';
|
import ToggleButton from '@mui/material/ToggleButton';
|
||||||
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
|
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
|
||||||
|
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
|
||||||
|
import PointOfSaleIcon from '@mui/icons-material/PointOfSale';
|
||||||
import { enqueueSnackbar } from 'notistack';
|
import { enqueueSnackbar } from 'notistack';
|
||||||
import BackendError from './BackendError';
|
import BackendError from './BackendError';
|
||||||
import SingleLineTextEdit from './SingleLineTextEdit';
|
import SingleLineTextEdit from './SingleLineTextEdit';
|
||||||
@@ -25,8 +34,18 @@ import {
|
|||||||
selectCurrentCompany,
|
selectCurrentCompany,
|
||||||
updateCompany as updateCompanyThunk,
|
updateCompany as updateCompanyThunk,
|
||||||
} from '../store/companySlice';
|
} from '../store/companySlice';
|
||||||
|
import {
|
||||||
|
loadPosterminals,
|
||||||
|
createPosterminal,
|
||||||
|
deletePosterminal,
|
||||||
|
enableAllTerminalCategories,
|
||||||
|
selectPosterminals,
|
||||||
|
selectPosterminalStatus,
|
||||||
|
} from '../store/posterminalSlice';
|
||||||
|
import { loadCategoryTree } from '../store/categorySlice';
|
||||||
import { ISO639_OPTIONS, parseTaxRate } from '../utils/companyForm';
|
import { ISO639_OPTIONS, parseTaxRate } from '../utils/companyForm';
|
||||||
import { backendErrorFrom, isBackendFailure } from '../utils/backendError';
|
import { backendErrorFrom, isBackendFailure } from '../utils/backendError';
|
||||||
|
import { notifyError } from '../utils/notify';
|
||||||
|
|
||||||
class CompanySettingsDialog extends Component {
|
class CompanySettingsDialog extends Component {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
@@ -38,9 +57,15 @@ class CompanySettingsDialog extends Component {
|
|||||||
iso639: 'de',
|
iso639: 'de',
|
||||||
taxrate: '',
|
taxrate: '',
|
||||||
reducedtaxrate: '',
|
reducedtaxrate: '',
|
||||||
|
newTerminalName: '',
|
||||||
|
creatingTerminal: false,
|
||||||
|
enableAllForNew: true,
|
||||||
};
|
};
|
||||||
this.handleSubmit = this.handleSubmit.bind(this);
|
this.handleSubmit = this.handleSubmit.bind(this);
|
||||||
this.syncFormFromCompany = this.syncFormFromCompany.bind(this);
|
this.syncFormFromCompany = this.syncFormFromCompany.bind(this);
|
||||||
|
this.handleCreateTerminal = this.handleCreateTerminal.bind(this);
|
||||||
|
this.handleDeleteTerminal = this.handleDeleteTerminal.bind(this);
|
||||||
|
this.handleEnableAllCategories = this.handleEnableAllCategories.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate(prevProps) {
|
componentDidUpdate(prevProps) {
|
||||||
@@ -52,6 +77,10 @@ class CompanySettingsDialog extends Component {
|
|||||||
} else if (companyStatus !== 'loading') {
|
} else if (companyStatus !== 'loading') {
|
||||||
void this.props.loadCurrentCompany();
|
void this.props.loadCurrentCompany();
|
||||||
}
|
}
|
||||||
|
// Load posterminals when dialog opens
|
||||||
|
if (this.props.posterminalStatus !== 'loading') {
|
||||||
|
void this.props.loadPosterminals();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (open && company && company !== prevProps.company) {
|
if (open && company && company !== prevProps.company) {
|
||||||
this.syncFormFromCompany(company);
|
this.syncFormFromCompany(company);
|
||||||
@@ -67,6 +96,56 @@ class CompanySettingsDialog extends Component {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async handleCreateTerminal() {
|
||||||
|
const { newTerminalName, enableAllForNew } = this.state;
|
||||||
|
const trimmed = newTerminalName.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
enqueueSnackbar('Terminalname ist erforderlich.', { variant: 'error' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setState({ creatingTerminal: true });
|
||||||
|
try {
|
||||||
|
const terminal = await this.props.createPosterminal({ name: trimmed }).unwrap();
|
||||||
|
|
||||||
|
// If "enable all" toggle is on, enable all categories for this terminal
|
||||||
|
if (enableAllForNew && terminal.id) {
|
||||||
|
await this.props.enableAllTerminalCategories({ terminalId: terminal.id }).unwrap();
|
||||||
|
// Reload category tree so tiles reflect the updated posterminalIds
|
||||||
|
this.props.loadCategoryTree();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setState({ newTerminalName: '' });
|
||||||
|
} catch (err) {
|
||||||
|
notifyError(err);
|
||||||
|
} finally {
|
||||||
|
this.setState({ creatingTerminal: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleDeleteTerminal(terminal) {
|
||||||
|
try {
|
||||||
|
await this.props.deletePosterminal({ terminalId: terminal.id }).unwrap();
|
||||||
|
} catch (err) {
|
||||||
|
notifyError(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleEnableAllCategories(terminal) {
|
||||||
|
try {
|
||||||
|
const hasCategories = terminal.categoryIds && terminal.categoryIds.length > 0;
|
||||||
|
if (hasCategories) {
|
||||||
|
await this.props.disableAllTerminalCategories({ terminalId: terminal.id }).unwrap();
|
||||||
|
enqueueSnackbar('Alle Kategorien für Terminal deaktiviert.', { variant: 'success' });
|
||||||
|
} else {
|
||||||
|
await this.props.enableAllTerminalCategories({ terminalId: terminal.id }).unwrap();
|
||||||
|
enqueueSnackbar('Alle Kategorien für Terminal aktiviert.', { variant: 'success' });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
notifyError(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async handleSubmit(event) {
|
async handleSubmit(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const { onClose } = this.props;
|
const { onClose } = this.props;
|
||||||
@@ -200,6 +279,77 @@ class CompanySettingsDialog extends Component {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
|
<Divider sx={{ my: 1 }} />
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
{this.props.posterminalStatus === 'loading' ? (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 2 }}>
|
||||||
|
<CircularProgress size={24} />
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<List dense sx={{ py: 0 }}>
|
||||||
|
{this.props.terminals.map((terminal) => (
|
||||||
|
<ListItem
|
||||||
|
key={terminal.id}
|
||||||
|
sx={{ px: 0, py: 0.5 }}
|
||||||
|
>
|
||||||
|
<PointOfSaleIcon sx={{ mr: 1, color: 'text.secondary', fontSize: 20 }} />
|
||||||
|
<ListItemText
|
||||||
|
primary={terminal.name}
|
||||||
|
secondary={`PIN: ${terminal.pairingpin}`}
|
||||||
|
/>
|
||||||
|
<ListItemSecondaryAction>
|
||||||
|
<IconButton
|
||||||
|
edge="end"
|
||||||
|
size="small"
|
||||||
|
onClick={() => this.handleDeleteTerminal(terminal)}
|
||||||
|
disabled={busy || this.state.creatingTerminal}
|
||||||
|
>
|
||||||
|
<DeleteOutlinedIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</ListItemSecondaryAction>
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
|
||||||
|
<Box sx={{ mt: 1, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
placeholder="Neues Terminal..."
|
||||||
|
value={this.state.newTerminalName}
|
||||||
|
onChange={(e) => this.setState({ newTerminalName: e.target.value })}
|
||||||
|
disabled={busy || this.state.creatingTerminal}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
this.handleCreateTerminal();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
sx={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||||
|
<PointOfSaleIcon sx={{ fontSize: 18, color: 'text.secondary' }} />
|
||||||
|
<Switch
|
||||||
|
size="small"
|
||||||
|
checked={this.state.enableAllForNew}
|
||||||
|
onChange={(e) => this.setState({ enableAllForNew: e.target.checked })}
|
||||||
|
disabled={busy || this.state.creatingTerminal}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
size="small"
|
||||||
|
onClick={this.handleCreateTerminal}
|
||||||
|
disabled={busy || this.state.creatingTerminal || !this.state.newTerminalName.trim()}
|
||||||
|
>
|
||||||
|
Hinzufügen
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
@@ -221,11 +371,18 @@ const mapStateToProps = (state) => ({
|
|||||||
user: selectAuthUser(state),
|
user: selectAuthUser(state),
|
||||||
company: selectCurrentCompany(state),
|
company: selectCurrentCompany(state),
|
||||||
companyStatus: selectCompanyStatus(state),
|
companyStatus: selectCompanyStatus(state),
|
||||||
|
terminals: selectPosterminals(state),
|
||||||
|
posterminalStatus: selectPosterminalStatus(state),
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapDispatchToProps = (dispatch) => ({
|
const mapDispatchToProps = (dispatch) => ({
|
||||||
loadCurrentCompany: () => dispatch(loadCurrentCompany()),
|
loadCurrentCompany: () => dispatch(loadCurrentCompany()),
|
||||||
updateCompany: (payload) => dispatch(updateCompanyThunk(payload)),
|
updateCompany: (payload) => dispatch(updateCompanyThunk(payload)),
|
||||||
|
loadPosterminals: () => dispatch(loadPosterminals()),
|
||||||
|
createPosterminal: (payload) => dispatch(createPosterminal(payload)),
|
||||||
|
deletePosterminal: (payload) => dispatch(deletePosterminal(payload)),
|
||||||
|
enableAllTerminalCategories: (payload) => dispatch(enableAllTerminalCategories(payload)),
|
||||||
|
loadCategoryTree: () => dispatch(loadCategoryTree()),
|
||||||
});
|
});
|
||||||
|
|
||||||
export default connect(mapStateToProps, mapDispatchToProps)(CompanySettingsDialog);
|
export default connect(mapStateToProps, mapDispatchToProps)(CompanySettingsDialog);
|
||||||
|
|||||||
@@ -2,18 +2,22 @@ import { Component } from 'react';
|
|||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
import IconButton from '@mui/material/IconButton';
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import Switch from '@mui/material/Switch';
|
||||||
import DragHandleIcon from '@mui/icons-material/DragHandle';
|
import DragHandleIcon from '@mui/icons-material/DragHandle';
|
||||||
import AccountTreeIcon from '@mui/icons-material/AccountTree';
|
import AccountTreeIcon from '@mui/icons-material/AccountTree';
|
||||||
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
||||||
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||||
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
|
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
|
||||||
import { Tile, tileBaseSx } from './Tile';
|
import SettingsIcon from '@mui/icons-material/Settings';
|
||||||
|
import PointOfSaleIcon from '@mui/icons-material/PointOfSale';
|
||||||
|
import { tileBaseSx } from './Tile';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Top edit band: left = reorder handle (SortableJS), right = drop-into-subcategory
|
* Top row (1/3): sort handle | settings toggle | move handle.
|
||||||
* handle (Shopify Draggable Droppable).
|
|
||||||
*/
|
*/
|
||||||
function TopBand() {
|
function TopBand({ onSettingsClick, settingsTab, terminalCount }) {
|
||||||
|
// settingsTab >= 0 means we're on a terminal tab
|
||||||
|
const isTerminalMode = settingsTab >= 0 && settingsTab < terminalCount;
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
@@ -36,6 +40,20 @@ function TopBand() {
|
|||||||
>
|
>
|
||||||
<DragHandleIcon sx={{ fontSize: 36 }} />
|
<DragHandleIcon sx={{ fontSize: 36 }} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
aria-label="Einstellungen"
|
||||||
|
disableRipple
|
||||||
|
onClick={onSettingsClick}
|
||||||
|
sx={{
|
||||||
|
color: isTerminalMode ? 'primary.main' : 'action.active',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isTerminalMode ? (
|
||||||
|
<PointOfSaleIcon sx={{ fontSize: 28 }} />
|
||||||
|
) : (
|
||||||
|
<SettingsIcon sx={{ fontSize: 28 }} />
|
||||||
|
)}
|
||||||
|
</IconButton>
|
||||||
<IconButton
|
<IconButton
|
||||||
className="droppable-handle"
|
className="droppable-handle"
|
||||||
aria-label="In Unterkategorie ablegen"
|
aria-label="In Unterkategorie ablegen"
|
||||||
@@ -49,10 +67,48 @@ function TopBand() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared grey name band spanning the middle third.
|
* Middle row (1/3): gray band — TextField or terminal toggle.
|
||||||
*/
|
*/
|
||||||
function NameBand({ label, nameInput }) {
|
function MiddleBand({ settingsTab, nameInput, terminals, categoryPosterminalIds, onTerminalToggle }) {
|
||||||
if (label == null) return null;
|
console.log('[MiddleBand] settingsTab:', settingsTab, 'terminals:', terminals?.length, 'categoryPosterminalIds:', categoryPosterminalIds);
|
||||||
|
// Show terminal-specific toggle when on a terminal tab
|
||||||
|
if (settingsTab >= 0 && settingsTab < terminals.length) {
|
||||||
|
const terminal = terminals[settingsTab];
|
||||||
|
const isEnabled = categoryPosterminalIds.includes(terminal.id);
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '33.33%',
|
||||||
|
bottom: '33.33%',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
bgcolor: 'grey.300',
|
||||||
|
px: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<PointOfSaleIcon sx={{ fontSize: 20, color: 'text.secondary' }} />
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
noWrap
|
||||||
|
sx={{ maxWidth: 80, color: 'text.secondary' }}
|
||||||
|
>
|
||||||
|
{terminal.name}
|
||||||
|
</Typography>
|
||||||
|
<Switch
|
||||||
|
checked={isEnabled}
|
||||||
|
onChange={(e) => onTerminalToggle?.(terminal.id, e.target.checked)}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
@@ -68,24 +124,17 @@ function NameBand({ label, nameInput }) {
|
|||||||
px: 1,
|
px: 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{nameInput ?? (
|
{nameInput}
|
||||||
<Typography
|
|
||||||
variant="body2"
|
|
||||||
fontWeight={600}
|
|
||||||
noWrap
|
|
||||||
sx={{ lineHeight: 1.2, color: 'text.primary' }}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bottom edit band with left/right chevron reorder buttons and a center delete.
|
* Bottom row (1/3): split into two sub-rows (each 1/6 of tile).
|
||||||
|
* Upper: click zone → navigate into category.
|
||||||
|
* Lower: gray band — left chevron | delete | right chevron.
|
||||||
*/
|
*/
|
||||||
function EditBand({ onMoveLeft, onMoveRight, onDelete, canLeft, canRight }) {
|
function BottomRow({ onOpen, editBand }) {
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
@@ -93,23 +142,67 @@ function EditBand({ onMoveLeft, onMoveRight, onDelete, canLeft, canRight }) {
|
|||||||
bottom: 0,
|
bottom: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
height: '16.66%',
|
height: '33.33%',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
flexDirection: 'column',
|
||||||
justifyContent: 'space-between',
|
|
||||||
px: 0.5,
|
|
||||||
bgcolor: 'grey.400',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<IconButton className="sortable-handle-btn" size="small" aria-label="Nach links verschieben" disabled={!canLeft} onClick={onMoveLeft}>
|
{/* Upper half: click-through to open category */}
|
||||||
<ChevronLeftIcon sx={{ fontSize: 18 }} />
|
<Box
|
||||||
</IconButton>
|
role="button"
|
||||||
<IconButton className="sortable-handle-btn" size="small" aria-label="Löschen" onClick={onDelete}>
|
tabIndex={0}
|
||||||
<DeleteOutlinedIcon sx={{ fontSize: 18 }} />
|
onClick={onOpen}
|
||||||
</IconButton>
|
onKeyDown={(e) => {
|
||||||
<IconButton className="sortable-handle-btn" size="small" aria-label="Nach rechts verschieben" disabled={!canRight} onClick={onMoveRight}>
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
<ChevronRightIcon sx={{ fontSize: 18 }} />
|
e.preventDefault();
|
||||||
</IconButton>
|
onOpen?.();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
cursor: 'pointer',
|
||||||
|
'&:hover': { bgcolor: 'action.hover' },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Lower half: reorder & delete */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
height: '50%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
px: 0.5,
|
||||||
|
bgcolor: 'grey.400',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
className="sortable-handle-btn"
|
||||||
|
size="small"
|
||||||
|
aria-label="Nach links verschieben"
|
||||||
|
disabled={!editBand.canLeft}
|
||||||
|
onClick={editBand.onMoveLeft}
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon sx={{ fontSize: 18 }} />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
className="sortable-handle-btn"
|
||||||
|
size="small"
|
||||||
|
aria-label="Löschen"
|
||||||
|
onClick={editBand.onDelete}
|
||||||
|
>
|
||||||
|
<DeleteOutlinedIcon sx={{ fontSize: 18 }} />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
className="sortable-handle-btn"
|
||||||
|
size="small"
|
||||||
|
aria-label="Nach rechts verschieben"
|
||||||
|
disabled={!editBand.canRight}
|
||||||
|
onClick={editBand.onMoveRight}
|
||||||
|
>
|
||||||
|
<ChevronRightIcon sx={{ fontSize: 18 }} />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -117,24 +210,105 @@ function EditBand({ onMoveLeft, onMoveRight, onDelete, canLeft, canRight }) {
|
|||||||
/**
|
/**
|
||||||
* Category tile (normal mode): no icon, click navigates to the category.
|
* Category tile (normal mode): no icon, click navigates to the category.
|
||||||
*/
|
*/
|
||||||
export class CategoryTile extends Tile {
|
export class CategoryTile extends Component {
|
||||||
renderIcon() {
|
handleClick = () => {
|
||||||
return null;
|
this.props.onClick?.();
|
||||||
}
|
};
|
||||||
|
|
||||||
renderLabel() {
|
renderLabel() {
|
||||||
const { label, nameInput } = this.props;
|
const { label, nameInput } = this.props;
|
||||||
return <NameBand label={label} nameInput={nameInput} />;
|
if (label == null) return null;
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '33.33%',
|
||||||
|
bottom: '33.33%',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
bgcolor: 'grey.300',
|
||||||
|
px: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{nameInput ?? (
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
fontWeight={600}
|
||||||
|
noWrap
|
||||||
|
sx={{ lineHeight: 1.2, color: 'text.primary' }}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { sx, onClick: _onClick, label, ...rest } = this.props;
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
component="button"
|
||||||
|
type="button"
|
||||||
|
onClick={this.handleClick}
|
||||||
|
{...rest}
|
||||||
|
sx={{
|
||||||
|
...tileBaseSx,
|
||||||
|
position: 'relative',
|
||||||
|
...sx,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{this.renderLabel()}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Category tile for edit mode: no ButtonBase (no ripple, no click navigation),
|
* Category tile for edit mode: three rows of 1/3 each.
|
||||||
* always shows the bottom edit band with chevron reorder/delete actions.
|
*
|
||||||
|
* Top row: sort-handle | settings toggle | drop-handle
|
||||||
|
* Middle row: gray band — TextField or POS toggle
|
||||||
|
* Bottom row: upper half = open-category click zone
|
||||||
|
* lower half = gray band with chevrons + delete
|
||||||
*/
|
*/
|
||||||
export class CategoryEditTile extends Component {
|
export class CategoryEditTile extends Component {
|
||||||
|
state = { settingsTab: -1 }; // -1 = rename, 0..n-1 = terminal index
|
||||||
|
|
||||||
|
handleSettingsToggle = () => {
|
||||||
|
const { terminals } = this.props;
|
||||||
|
const terminalCount = terminals?.length || 0;
|
||||||
|
this.setState((prev) => {
|
||||||
|
const next = prev.settingsTab + 1;
|
||||||
|
const nextTab = next > terminalCount ? -1 : next;
|
||||||
|
console.log('[CategoryEditTile] settings toggle', {
|
||||||
|
terminals: terminals?.length,
|
||||||
|
terminalCount,
|
||||||
|
from: prev.settingsTab,
|
||||||
|
to: nextTab,
|
||||||
|
});
|
||||||
|
return { settingsTab: nextTab };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { label, nameInput, editBand, sx, ...rest } = this.props;
|
const {
|
||||||
|
label,
|
||||||
|
nameInput,
|
||||||
|
editBand,
|
||||||
|
onOpen,
|
||||||
|
terminals,
|
||||||
|
categoryPosterminalIds,
|
||||||
|
onTerminalToggle,
|
||||||
|
hasTerminals: _hasTerminals,
|
||||||
|
sx,
|
||||||
|
...rest
|
||||||
|
} = this.props;
|
||||||
|
const { settingsTab } = this.state;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
{...rest}
|
{...rest}
|
||||||
@@ -146,15 +320,19 @@ export class CategoryEditTile extends Component {
|
|||||||
...sx,
|
...sx,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TopBand />
|
<TopBand
|
||||||
<NameBand label={label} nameInput={nameInput} />
|
onSettingsClick={this.handleSettingsToggle}
|
||||||
<EditBand
|
settingsTab={settingsTab}
|
||||||
onMoveLeft={editBand.onMoveLeft}
|
terminalCount={terminals?.length || 0}
|
||||||
onMoveRight={editBand.onMoveRight}
|
|
||||||
onDelete={editBand.onDelete}
|
|
||||||
canLeft={editBand.canLeft}
|
|
||||||
canRight={editBand.canRight}
|
|
||||||
/>
|
/>
|
||||||
|
<MiddleBand
|
||||||
|
settingsTab={settingsTab}
|
||||||
|
nameInput={nameInput}
|
||||||
|
terminals={terminals}
|
||||||
|
categoryPosterminalIds={categoryPosterminalIds}
|
||||||
|
onTerminalToggle={onTerminalToggle}
|
||||||
|
/>
|
||||||
|
<BottomRow onOpen={onOpen} editBand={editBand} />
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
import withCategoryRoute from './withCategoryRoute';
|
import withCategoryRoute from './withCategoryRoute';
|
||||||
import { artikelDetailPath, articleSku } from './articleRoute';
|
import { artikelDetailPath, articleSku } from './articleRoute';
|
||||||
import { selectCompanyId } from '../../store/companySlice';
|
import { selectCompanyId } from '../../store/companySlice';
|
||||||
|
import { selectHasPosterminals, selectPosterminals } from '../../store/posterminalSlice';
|
||||||
import {
|
import {
|
||||||
buildCategoryPath,
|
buildCategoryPath,
|
||||||
defaultCategoryPath,
|
defaultCategoryPath,
|
||||||
@@ -36,6 +37,7 @@ import {
|
|||||||
navigateToCategory,
|
navigateToCategory,
|
||||||
renameCategory,
|
renameCategory,
|
||||||
reorderCategories,
|
reorderCategories,
|
||||||
|
toggleCategoryPos,
|
||||||
selectCanNavigateBack,
|
selectCanNavigateBack,
|
||||||
selectCategoryArticles,
|
selectCategoryArticles,
|
||||||
selectCategoryArticlesStatus,
|
selectCategoryArticlesStatus,
|
||||||
@@ -46,6 +48,10 @@ import {
|
|||||||
selectChildCategories,
|
selectChildCategories,
|
||||||
selectRenameMode,
|
selectRenameMode,
|
||||||
} from '../../store/categorySlice';
|
} from '../../store/categorySlice';
|
||||||
|
import {
|
||||||
|
enableTerminalCategory,
|
||||||
|
disableTerminalCategory,
|
||||||
|
} from '../../store/posterminalSlice';
|
||||||
|
|
||||||
const gridSx = {
|
const gridSx = {
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
@@ -266,6 +272,8 @@ class ArtikelKategorienPage extends Component {
|
|||||||
childCategories,
|
childCategories,
|
||||||
articles,
|
articles,
|
||||||
renameMode,
|
renameMode,
|
||||||
|
hasTerminals,
|
||||||
|
terminals,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
if (!companyId) {
|
if (!companyId) {
|
||||||
@@ -340,6 +348,19 @@ class ArtikelKategorienPage extends Component {
|
|||||||
data-id={String(item.id)}
|
data-id={String(item.id)}
|
||||||
className="sortable-item"
|
className="sortable-item"
|
||||||
label={draftName}
|
label={draftName}
|
||||||
|
terminals={terminals}
|
||||||
|
categoryPosterminalIds={item.posterminalIds || []}
|
||||||
|
onTerminalToggle={(terminalId, enabled) => {
|
||||||
|
const thunk = enabled
|
||||||
|
? this.props.enableTerminalCategory
|
||||||
|
: this.props.disableTerminalCategory;
|
||||||
|
void thunk({ terminalId, categoryId: item.id })
|
||||||
|
.unwrap()
|
||||||
|
.then(() => this.props.loadCategoryTree())
|
||||||
|
.catch(notifyError);
|
||||||
|
}}
|
||||||
|
onOpen={() => this.navigateToCategoryById(item.id)}
|
||||||
|
hasTerminals={hasTerminals}
|
||||||
nameInput={
|
nameInput={
|
||||||
<TextField
|
<TextField
|
||||||
value={draftName}
|
value={draftName}
|
||||||
@@ -396,7 +417,7 @@ class ArtikelKategorienPage extends Component {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<NewCategoryTile parentId={currentCategoryId} />
|
{renameMode && <NewCategoryTile parentId={currentCategoryId} />}
|
||||||
{!renameMode && (
|
{!renameMode && (
|
||||||
<NewArticleTile
|
<NewArticleTile
|
||||||
parentId={currentCategoryId}
|
parentId={currentCategoryId}
|
||||||
@@ -428,6 +449,8 @@ const mapStateToProps = (state) => ({
|
|||||||
articles: selectCategoryArticles(state),
|
articles: selectCategoryArticles(state),
|
||||||
canGoBack: selectCanNavigateBack(state),
|
canGoBack: selectCanNavigateBack(state),
|
||||||
renameMode: selectRenameMode(state),
|
renameMode: selectRenameMode(state),
|
||||||
|
hasTerminals: selectHasPosterminals(state),
|
||||||
|
terminals: selectPosterminals(state),
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapDispatchToProps = {
|
const mapDispatchToProps = {
|
||||||
@@ -438,6 +461,9 @@ const mapDispatchToProps = {
|
|||||||
deleteCategory,
|
deleteCategory,
|
||||||
moveCategory,
|
moveCategory,
|
||||||
reorderCategories,
|
reorderCategories,
|
||||||
|
toggleCategoryPos,
|
||||||
|
enableTerminalCategory,
|
||||||
|
disableTerminalCategory,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default withCategoryRoute(connect(mapStateToProps, mapDispatchToProps)(ArtikelKategorienPage));
|
export default withCategoryRoute(connect(mapStateToProps, mapDispatchToProps)(ArtikelKategorienPage));
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export function normalizeCategoryNode(item) {
|
|||||||
id: item.id,
|
id: item.id,
|
||||||
parentId: item.parentId,
|
parentId: item.parentId,
|
||||||
name: item.name == null ? null : String(item.name).trim() || null,
|
name: item.name == null ? null : String(item.name).trim() || null,
|
||||||
|
posterminalIds: Array.isArray(item.posterminalIds) ? item.posterminalIds : [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,6 +220,24 @@ export const deleteCategory = createAsyncThunk(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle the POS (Point of Sale) flag on a category.
|
||||||
|
* Backend: PATCH /api/category/:id Body: { pos: boolean } → { category }.
|
||||||
|
* PROPOSED — backend needs to implement this field.
|
||||||
|
*/
|
||||||
|
export const toggleCategoryPos = createAsyncThunk(
|
||||||
|
'category/togglePos',
|
||||||
|
async ({ categoryId, pos }, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const data = await category.updatePos(categoryId, pos);
|
||||||
|
requireObject(data.category, 'PATCH /api/category/:id.category (pos)');
|
||||||
|
return normalizeCategoryNode(data.category);
|
||||||
|
} catch (err) {
|
||||||
|
return catchBackendError(rejectWithValue)(err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const initialState = {
|
const initialState = {
|
||||||
companyId: null,
|
companyId: null,
|
||||||
languageId: null,
|
languageId: null,
|
||||||
@@ -403,6 +422,15 @@ const categorySlice = createSlice({
|
|||||||
state.articlesStatus = 'succeeded';
|
state.articlesStatus = 'succeeded';
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
.addCase(toggleCategoryPos.fulfilled, (state, action) => {
|
||||||
|
const updated = action.payload;
|
||||||
|
if (updated == null || state.categories == null) return;
|
||||||
|
const key = String(updated.id);
|
||||||
|
const existing = state.categories[key];
|
||||||
|
if (existing) {
|
||||||
|
state.categories[key] = { ...existing, pos: updated.pos };
|
||||||
|
}
|
||||||
|
})
|
||||||
.addCase(loadCurrentCompany.fulfilled, (state, action) => {
|
.addCase(loadCurrentCompany.fulfilled, (state, action) => {
|
||||||
const nextCompanyId = action.payload?.id ?? null;
|
const nextCompanyId = action.payload?.id ?? null;
|
||||||
if (nextCompanyId != null && nextCompanyId !== state.companyId) {
|
if (nextCompanyId != null && nextCompanyId !== state.companyId) {
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ import authReducer from './authSlice';
|
|||||||
import brandReducer, { brandLoadListener } from './brandSlice';
|
import brandReducer, { brandLoadListener } from './brandSlice';
|
||||||
import categoryReducer from './categorySlice';
|
import categoryReducer from './categorySlice';
|
||||||
import companyReducer, { companyLoadListener } from './companySlice';
|
import companyReducer, { companyLoadListener } from './companySlice';
|
||||||
|
import posterminalReducer, { posterminalLoadListener } from './posterminalSlice';
|
||||||
import unitReducer, { unitLoadListener } from './unitSlice';
|
import unitReducer, { unitLoadListener } from './unitSlice';
|
||||||
|
|
||||||
const listenerMiddleware = createListenerMiddleware();
|
const listenerMiddleware = createListenerMiddleware();
|
||||||
companyLoadListener(listenerMiddleware);
|
companyLoadListener(listenerMiddleware);
|
||||||
unitLoadListener(listenerMiddleware);
|
unitLoadListener(listenerMiddleware);
|
||||||
brandLoadListener(listenerMiddleware);
|
brandLoadListener(listenerMiddleware);
|
||||||
|
posterminalLoadListener(listenerMiddleware);
|
||||||
|
|
||||||
function reduxDevLogger() {
|
function reduxDevLogger() {
|
||||||
return (storeApi) => (next) => (action) => {
|
return (storeApi) => (next) => (action) => {
|
||||||
@@ -18,6 +20,7 @@ function reduxDevLogger() {
|
|||||||
!action.type?.startsWith('company/') &&
|
!action.type?.startsWith('company/') &&
|
||||||
!action.type?.startsWith('category/') &&
|
!action.type?.startsWith('category/') &&
|
||||||
!action.type?.startsWith('brand/') &&
|
!action.type?.startsWith('brand/') &&
|
||||||
|
!action.type?.startsWith('posterminal/') &&
|
||||||
!action.type?.startsWith('unit/'))
|
!action.type?.startsWith('unit/'))
|
||||||
) {
|
) {
|
||||||
return next(action);
|
return next(action);
|
||||||
@@ -34,6 +37,7 @@ function reduxDevLogger() {
|
|||||||
},
|
},
|
||||||
company: after.company.current,
|
company: after.company.current,
|
||||||
brands: after.brand.brands.length,
|
brands: after.brand.brands.length,
|
||||||
|
posterminals: after.posterminal.terminals.length,
|
||||||
units: after.unit.units.length,
|
units: after.unit.units.length,
|
||||||
});
|
});
|
||||||
return result;
|
return result;
|
||||||
@@ -46,6 +50,7 @@ export const store = configureStore({
|
|||||||
company: companyReducer,
|
company: companyReducer,
|
||||||
category: categoryReducer,
|
category: categoryReducer,
|
||||||
brand: brandReducer,
|
brand: brandReducer,
|
||||||
|
posterminal: posterminalReducer,
|
||||||
unit: unitReducer,
|
unit: unitReducer,
|
||||||
},
|
},
|
||||||
middleware: (getDefaultMiddleware) =>
|
middleware: (getDefaultMiddleware) =>
|
||||||
|
|||||||
271
src/store/posterminalSlice.js
Normal file
271
src/store/posterminalSlice.js
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
|
||||||
|
import { posterminal } from '../api';
|
||||||
|
import { requireArray, requireDefined, requireObject } from '../utils/apiShape';
|
||||||
|
import { catchBackendError, resolveBackendError } from '../utils/backendError';
|
||||||
|
import {
|
||||||
|
bootstrapSession,
|
||||||
|
createCompany,
|
||||||
|
loginWithGoogle,
|
||||||
|
logout,
|
||||||
|
selectCompany,
|
||||||
|
} from './authSlice';
|
||||||
|
import { loadCurrentCompany } from './companySlice';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load all posterminals for the selected company.
|
||||||
|
* GET /api/posterminal → { posterminals: [...] }
|
||||||
|
*/
|
||||||
|
export const loadPosterminals = createAsyncThunk(
|
||||||
|
'posterminal/load',
|
||||||
|
async (_, { rejectWithValue, getState }) => {
|
||||||
|
try {
|
||||||
|
const companyId = getState().company.current?.id;
|
||||||
|
requireDefined(companyId, 'company.id');
|
||||||
|
const data = await posterminal.list();
|
||||||
|
requireObject(data, 'GET /api/posterminal');
|
||||||
|
const list = requireArray(data.posterminals, 'posterminals');
|
||||||
|
return { companyId, terminals: list };
|
||||||
|
} catch (err) {
|
||||||
|
return catchBackendError(rejectWithValue)(err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new posterminal.
|
||||||
|
* PUT /api/posterminal → { posterminal: { id, name, token, pairingpin } }
|
||||||
|
*/
|
||||||
|
export const createPosterminal = createAsyncThunk(
|
||||||
|
'posterminal/create',
|
||||||
|
async ({ name }, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const data = await posterminal.create({ name });
|
||||||
|
requireObject(data, 'PUT /api/posterminal');
|
||||||
|
requireObject(data.posterminal, 'posterminal');
|
||||||
|
requireDefined(data.posterminal.id, 'posterminal.id');
|
||||||
|
return data.posterminal;
|
||||||
|
} catch (err) {
|
||||||
|
return catchBackendError(rejectWithValue)(err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a posterminal.
|
||||||
|
* DELETE /api/posterminal/:id → 204
|
||||||
|
*/
|
||||||
|
export const deletePosterminal = createAsyncThunk(
|
||||||
|
'posterminal/delete',
|
||||||
|
async ({ terminalId }, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
await posterminal.delete(terminalId);
|
||||||
|
return terminalId;
|
||||||
|
} catch (err) {
|
||||||
|
return catchBackendError(rejectWithValue)(err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable all categories on a terminal.
|
||||||
|
* PUT /api/posterminal/:id/all → { id, categoryIds }
|
||||||
|
*/
|
||||||
|
export const enableAllTerminalCategories = createAsyncThunk(
|
||||||
|
'posterminal/enableAll',
|
||||||
|
async ({ terminalId }, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const data = await posterminal.enableAllCategories(terminalId);
|
||||||
|
requireObject(data, 'PUT /api/posterminal/:id/all');
|
||||||
|
return data;
|
||||||
|
} catch (err) {
|
||||||
|
return catchBackendError(rejectWithValue)(err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disable all categories on a terminal.
|
||||||
|
* DELETE /api/posterminal/:id/all → { id, categoryIds }
|
||||||
|
*/
|
||||||
|
export const disableAllTerminalCategories = createAsyncThunk(
|
||||||
|
'posterminal/disableAll',
|
||||||
|
async ({ terminalId }, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const data = await posterminal.disableAllCategories(terminalId);
|
||||||
|
requireObject(data, 'DELETE /api/posterminal/:id/all');
|
||||||
|
return data;
|
||||||
|
} catch (err) {
|
||||||
|
return catchBackendError(rejectWithValue)(err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable a specific category on a terminal.
|
||||||
|
* PUT /api/posterminal/:id/category/:categoryId → { id, categoryIds }
|
||||||
|
*/
|
||||||
|
export const enableTerminalCategory = createAsyncThunk(
|
||||||
|
'posterminal/enableCategory',
|
||||||
|
async ({ terminalId, categoryId }, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const data = await posterminal.enableCategory(terminalId, categoryId);
|
||||||
|
requireObject(data, 'PUT /api/posterminal/:id/category/:categoryId');
|
||||||
|
return data;
|
||||||
|
} catch (err) {
|
||||||
|
return catchBackendError(rejectWithValue)(err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disable a specific category on a terminal.
|
||||||
|
* DELETE /api/posterminal/:id/category/:categoryId → { id, categoryIds }
|
||||||
|
*/
|
||||||
|
export const disableTerminalCategory = createAsyncThunk(
|
||||||
|
'posterminal/disableCategory',
|
||||||
|
async ({ terminalId, categoryId }, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const data = await posterminal.disableCategory(terminalId, categoryId);
|
||||||
|
requireObject(data, 'DELETE /api/posterminal/:id/category/:categoryId');
|
||||||
|
return data;
|
||||||
|
} catch (err) {
|
||||||
|
return catchBackendError(rejectWithValue)(err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const initialState = {
|
||||||
|
companyId: null,
|
||||||
|
terminals: [],
|
||||||
|
status: 'idle',
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const posterminalSlice = createSlice({
|
||||||
|
name: 'posterminal',
|
||||||
|
initialState,
|
||||||
|
reducers: {
|
||||||
|
resetPosterminalState() {
|
||||||
|
return initialState;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
extraReducers: (builder) => {
|
||||||
|
builder
|
||||||
|
// Load
|
||||||
|
.addCase(loadPosterminals.pending, (state) => {
|
||||||
|
state.status = 'loading';
|
||||||
|
state.error = null;
|
||||||
|
})
|
||||||
|
.addCase(loadPosterminals.fulfilled, (state, action) => {
|
||||||
|
state.status = 'succeeded';
|
||||||
|
state.companyId = action.payload.companyId;
|
||||||
|
state.terminals = action.payload.terminals;
|
||||||
|
})
|
||||||
|
.addCase(loadPosterminals.rejected, (state, action) => {
|
||||||
|
state.status = 'failed';
|
||||||
|
state.error = resolveBackendError(action);
|
||||||
|
})
|
||||||
|
// Create
|
||||||
|
.addCase(createPosterminal.pending, (state) => {
|
||||||
|
state.error = null;
|
||||||
|
})
|
||||||
|
.addCase(createPosterminal.fulfilled, (state, action) => {
|
||||||
|
state.terminals.push(action.payload);
|
||||||
|
})
|
||||||
|
.addCase(createPosterminal.rejected, (state, action) => {
|
||||||
|
state.error = resolveBackendError(action);
|
||||||
|
})
|
||||||
|
// Delete
|
||||||
|
.addCase(deletePosterminal.fulfilled, (state, action) => {
|
||||||
|
state.terminals = state.terminals.filter(
|
||||||
|
(t) => t.id !== action.payload,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.addCase(deletePosterminal.rejected, (state, action) => {
|
||||||
|
state.error = resolveBackendError(action);
|
||||||
|
})
|
||||||
|
// Enable all categories
|
||||||
|
.addCase(enableAllTerminalCategories.fulfilled, (state, action) => {
|
||||||
|
const { id, categoryIds } = action.payload;
|
||||||
|
const terminal = state.terminals.find((t) => t.id === id);
|
||||||
|
if (terminal) {
|
||||||
|
terminal.categoryIds = categoryIds;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.addCase(enableAllTerminalCategories.rejected, (state, action) => {
|
||||||
|
state.error = resolveBackendError(action);
|
||||||
|
})
|
||||||
|
// Disable all categories
|
||||||
|
.addCase(disableAllTerminalCategories.fulfilled, (state, action) => {
|
||||||
|
const { id, categoryIds } = action.payload;
|
||||||
|
const terminal = state.terminals.find((t) => t.id === id);
|
||||||
|
if (terminal) {
|
||||||
|
terminal.categoryIds = categoryIds;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.addCase(disableAllTerminalCategories.rejected, (state, action) => {
|
||||||
|
state.error = resolveBackendError(action);
|
||||||
|
})
|
||||||
|
// Enable single category
|
||||||
|
.addCase(enableTerminalCategory.fulfilled, (state, action) => {
|
||||||
|
const { id, categoryIds } = action.payload;
|
||||||
|
const terminal = state.terminals.find((t) => t.id === id);
|
||||||
|
if (terminal) {
|
||||||
|
terminal.categoryIds = categoryIds;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.addCase(enableTerminalCategory.rejected, (state, action) => {
|
||||||
|
state.error = resolveBackendError(action);
|
||||||
|
})
|
||||||
|
// Disable single category
|
||||||
|
.addCase(disableTerminalCategory.fulfilled, (state, action) => {
|
||||||
|
const { id, categoryIds } = action.payload;
|
||||||
|
const terminal = state.terminals.find((t) => t.id === id);
|
||||||
|
if (terminal) {
|
||||||
|
terminal.categoryIds = categoryIds;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.addCase(disableTerminalCategory.rejected, (state, action) => {
|
||||||
|
state.error = resolveBackendError(action);
|
||||||
|
})
|
||||||
|
// Reset on company change
|
||||||
|
.addCase(loadCurrentCompany.fulfilled, (state, action) => {
|
||||||
|
const nextCompanyId = action.payload?.id ?? null;
|
||||||
|
if (nextCompanyId != null && nextCompanyId !== state.companyId) {
|
||||||
|
return { ...initialState, companyId: nextCompanyId, status: 'idle' };
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// Reset on logout
|
||||||
|
.addCase(logout.fulfilled, () => initialState);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const { resetPosterminalState } = posterminalSlice.actions;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-load posterminals when the session/company is established.
|
||||||
|
* Mirrors brandLoadListener / unitLoadListener pattern.
|
||||||
|
*/
|
||||||
|
export function posterminalLoadListener(listenerMiddleware) {
|
||||||
|
listenerMiddleware.startListening({
|
||||||
|
matcher: (action) =>
|
||||||
|
bootstrapSession.fulfilled.match(action) ||
|
||||||
|
loginWithGoogle.fulfilled.match(action) ||
|
||||||
|
selectCompany.fulfilled.match(action) ||
|
||||||
|
createCompany.fulfilled.match(action),
|
||||||
|
effect: async (_action, api) => {
|
||||||
|
const companyId = api.getState().auth.user?.selectedCompanyId;
|
||||||
|
if (companyId) {
|
||||||
|
api.dispatch(loadPosterminals());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Selectors
|
||||||
|
export const selectPosterminals = (state) => state.posterminal.terminals;
|
||||||
|
export const selectPosterminalStatus = (state) => state.posterminal.status;
|
||||||
|
export const selectPosterminalError = (state) => state.posterminal.error;
|
||||||
|
export const selectHasPosterminals = (state) => state.posterminal.terminals.length > 0;
|
||||||
|
|
||||||
|
export default posterminalSlice.reducer;
|
||||||
Reference in New Issue
Block a user